diff --git a/fastbee-admin/src/main/java/com/fastbee/web/controller/system/SysUserSettingController.java b/fastbee-admin/src/main/java/com/fastbee/web/controller/system/SysUserSettingController.java
new file mode 100644
index 00000000..e4f4b7eb
--- /dev/null
+++ b/fastbee-admin/src/main/java/com/fastbee/web/controller/system/SysUserSettingController.java
@@ -0,0 +1,123 @@
+package com.fastbee.web.controller.system;
+
+import com.fastbee.common.annotation.Log;
+import com.fastbee.common.core.controller.BaseController;
+import com.fastbee.common.core.domain.AjaxResult;
+import com.fastbee.common.enums.BusinessType;
+import com.fastbee.common.utils.SecurityUtils;
+import com.fastbee.system.domain.LoginRedirectOption;
+import com.fastbee.system.domain.SysUserSetting;
+import com.fastbee.system.service.ISysUserSettingService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 个人系统设置
+ *
+ *
本接口只处理「当前登录用户自己的」设置,无需额外的菜单权限点:
+ * 能登录的用户天然有权读写自己的偏好。全局开关不在这里,
+ * 仍走 SysConfigController 的 /system/config(权限 system:config:edit)。
+ *
+ * @author Sumuxi
+ */
+@Api(tags = "个人设置")
+@RestController
+@RequestMapping("/system/userSetting")
+public class SysUserSettingController extends BaseController {
+
+ @Autowired
+ private ISysUserSettingService userSettingService;
+
+ @ApiOperation("获取当前用户的设置")
+ @GetMapping()
+ public AjaxResult get() {
+ Long userId = SecurityUtils.getUserId();
+ return AjaxResult.success(userSettingService.selectByUserId(userId));
+ }
+
+ @ApiOperation("获取登录落地页候选项")
+ @GetMapping("/loginRedirectOptions")
+ public AjaxResult loginRedirectOptions() {
+ return AjaxResult.success(userSettingService.listLoginRedirectOptions());
+ }
+
+ @ApiOperation("获取当前用户固定的任务栏标签")
+ @GetMapping("/pinnedTags")
+ public AjaxResult pinnedTags() {
+ return AjaxResult.success(userSettingService.getPinnedTags(SecurityUtils.getUserId()));
+ }
+
+ /**
+ * 固定标签按「单条增量」写入,不再提供整份列表的覆盖写
+ *
+ *
原先的 {@code PUT} 需要前端提交自己内存里的完整列表,而这份列表可能已经过期
+ * (另一个标签页/另一台设备刚改过),落库即把对方的改动静默抹掉。
+ * 现在前端只发「固定这一个 / 取消这一个」,服务端在事务里锁定读改写,
+ * 并发操作互不覆盖,也不依赖前端先拉全量列表。
+ */
+ @ApiOperation("固定一个任务栏标签")
+ @PostMapping("/pinnedTags")
+ public AjaxResult addPinnedTag(@RequestParam String path) {
+ userSettingService.addPinnedTag(SecurityUtils.getUserId(), path, SecurityUtils.getUsername());
+ return AjaxResult.success();
+ }
+
+ @ApiOperation("取消固定一个任务栏标签")
+ @DeleteMapping("/pinnedTags")
+ public AjaxResult removePinnedTag(@RequestParam String path) {
+ userSettingService.removePinnedTag(SecurityUtils.getUserId(), path, SecurityUtils.getUsername());
+ return AjaxResult.success();
+ }
+
+ /**
+ * 部分更新:只提交、也只写「用户改动过的那几项」
+ *
+ *
刻意不是整体覆盖写(PUT):设置页有多个互相独立的配置项,
+ * 整体覆盖会让「保存 A 配置」把 B 配置冲回默认值。
+ * 这里按入参中非 null 的字段逐列更新,未传的字段在 SQL 层面就碰不到。
+ *
用 PATCH 而非 PUT:PUT 的语义是整体替换,与部分更新名实不符。
+ */
+ @ApiOperation("部分更新当前用户的设置")
+ @Log(title = "个人设置", businessType = BusinessType.UPDATE)
+ @PatchMapping()
+ public AjaxResult update(@RequestBody SysUserSetting setting) {
+ if (setting.getLoginRedirect() == null
+ && setting.getQueryGatewayChild() == null
+ && setting.getShowChild() == null) {
+ return AjaxResult.error("没有需要保存的配置项");
+ }
+ // 只有在本次确实要改「登录后显示页面」时才做白名单校验;
+ // 未传(null)表示本次不改它,不能顺手用默认值把它覆盖掉
+ if (setting.getLoginRedirect() != null && !isValidLoginRedirect(setting.getLoginRedirect())) {
+ return AjaxResult.error("登录后跳转页面不合法,请从系统菜单中选择");
+ }
+ // 审计字段由服务端填充,不接受前端传入,避免越权写入他人操作名
+ userSettingService.updatePartial(SecurityUtils.getUserId(), setting, SecurityUtils.getUsername());
+ return AjaxResult.success();
+ }
+
+ /**
+ * 校验跳转目标为系统内已注册的一级页签
+ *
+ *
该值最终进入 router.push。白名单取自 sys_menu(已剔除停用/隐藏/外链/二级嵌套页),
+ * 比单纯校验「以 / 开头」更严:只能落到真实存在的菜单路由上,
+ * 既挡住开放重定向,也避免指向被删除的菜单导致登录后 404。
+ */
+ private boolean isValidLoginRedirect(String redirect) {
+ for (LoginRedirectOption option : userSettingService.listLoginRedirectOptions()) {
+ if (option.getPath().equals(redirect)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/fastbee-common/src/main/java/com/fastbee/common/constant/CacheConstants.java b/fastbee-common/src/main/java/com/fastbee/common/constant/CacheConstants.java
index e213bc6b..86455227 100644
--- a/fastbee-common/src/main/java/com/fastbee/common/constant/CacheConstants.java
+++ b/fastbee-common/src/main/java/com/fastbee/common/constant/CacheConstants.java
@@ -31,6 +31,11 @@ public class CacheConstants {
*/
public static final String SYS_DICT_KEY = "sys_dict:";
+ /**
+ * 用户系统设置 cache key(后接 userId)
+ */
+ public static final String SYS_USER_SETTING_KEY = "sys_user_setting:";
+
/**
* 数据库查询 cache key
*/
diff --git a/fastbee-common/src/main/java/com/fastbee/common/constant/UserSettingConstants.java b/fastbee-common/src/main/java/com/fastbee/common/constant/UserSettingConstants.java
new file mode 100644
index 00000000..5bc2c663
--- /dev/null
+++ b/fastbee-common/src/main/java/com/fastbee/common/constant/UserSettingConstants.java
@@ -0,0 +1,106 @@
+package com.fastbee.common.constant;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 用户系统设置相关常量
+ *
+ *
维度判据:读取方能否拿到当前登录用户。
+ * 拿得到(HTTP 请求,有 SecurityContext)→ 落 {@code sys_user_setting},per-user;
+ * 拿不到(设备上电 {@code @Async} 线程)→ 落 {@code sys_config},全局。
+ *
+ * @author Sumuxi
+ */
+public class UserSettingConstants {
+
+ /**
+ * 登录后默认跳转路由
+ *
+ *
取 /bigscreen:未在设置里指定页面的账号保持原有的数据大屏落地行为,
+ * 不因引入本功能而改变既有用户的使用习惯。
+ * 对应 yack-web router/index.js constantRoutes 中 hidden 的顶层大屏路由。
+ */
+ public static final String DEFAULT_LOGIN_REDIRECT = "/bigscreen";
+
+ /**
+ * 内置固定路由:不在 sys_menu 菜单表中,但可作为登录落地页
+ *
+ *
这两条由前端 constantRoutes 硬编码注册(菜单表里查不到):
+ * /index —— 首页,Layout 的默认子路由
+ * /bigscreen—— 数据大屏,hidden 菜单,任何角色都可访问
+ * 候选项 = 内置路由 + sys_menu 一级页签,缺一则默认值 /index 会被白名单拒绝。
+ */
+ public static final List BUILTIN_REDIRECTS = Collections.unmodifiableList(Arrays.asList(
+ new BuiltinRedirect("/index", "首页"),
+ new BuiltinRedirect("/bigscreen", "数据大屏")
+ ));
+
+ /**
+ * 是否下发读取设备 IMEI/ICCID 指令的全局配置 key
+ *
+ * 读取方为设备上电异步线程,无登录上下文,故存 {@code sys_config} 而非用户设置表。
+ * 取代原实现中运维经 {@code BackendController#/set} 手动写入的裸 Redis key {@code get_imei_iccid}。
+ */
+ public static final String READ_IMEI_ICCID_KEY = "iot.power_on.read_imei_iccid";
+
+ /**
+ * 固定标签的「未固定」空值
+ *
+ *
列约束为 NOT NULL DEFAULT ('[]'),不存 NULL、不存空串。
+ * 存空数组而非 NULL,读侧无需判空即可安全反序列化。
+ */
+ public static final String EMPTY_PINNED_TAGS = "[]";
+
+ /**
+ * 是否查询网关子设备的默认值
+ *
+ *
语义:{@code /iot/device/shortList} 按设备编号查询时({@code serialNumber} 非空):
+ *
+ * - 若命中的是网关子设备编号,则换算为其所属的网关设备({@code ifnull(gw_dev_code, serial_number)});
+ * - 放开产品类型为「网关子设备(4)」的过滤 —— 这类设备 {@code gw_dev_code} 为空、实为独立设备,
+ * 只是产品被标成 4,默认在列表中被隐藏(否则设备管理页会多出一大批),仅在按编号精确定位时可见。
+ *
+ * 列表范围本身不变:{@code gw_dev_code is null} 恒生效,不带编号时 {@code device_type != 4} 依然过滤,
+ * 因此列表中永远不会出现挂在网关下的真子设备,默认列表也不会多出 device_type=4 的设备。
+ * 默认 false = 与引入本开关前完全一致。
+ *
维度归属:读该值的入口 {@code DeviceController#shortList} 有 SecurityContext,故为 per-user 设置。
+ */
+ public static final boolean DEFAULT_QUERY_GATEWAY_CHILD = false;
+
+ /**
+ * 设备管理列表是否包含下级机构数据的默认值
+ *
+ *
语义对应 {@code Device.showChild}:仅在机构用户({@code deptId} 非空)时参与 SQL,
+ * 开启时 {@code tenant_id} 落在「本机构及全部下级机构用户」集合内,关闭时仅本机构。
+ * 原实现是设备管理页的勾选框 + 浏览器 localStorage(key {@code device-showChild}),
+ * 换浏览器/换电脑就丢,现改为落库、由「系统管理 → 系统设置」页维护。
+ *
默认 false = 与引入本开关前完全一致(默认列表不含下级机构数据)。
+ */
+ public static final boolean DEFAULT_SHOW_CHILD = false;
+
+ private UserSettingConstants() {
+ }
+
+ /**
+ * 内置固定路由项
+ */
+ public static class BuiltinRedirect {
+ private final String path;
+ private final String name;
+
+ public BuiltinRedirect(String path, String name) {
+ this.path = path;
+ this.name = name;
+ }
+
+ public String getPath() {
+ return path;
+ }
+
+ public String getName() {
+ return name;
+ }
+ }
+}
diff --git a/fastbee-common/src/main/java/com/fastbee/common/core/redis/RedisCache.java b/fastbee-common/src/main/java/com/fastbee/common/core/redis/RedisCache.java
index d74beacf..f6e6be99 100644
--- a/fastbee-common/src/main/java/com/fastbee/common/core/redis/RedisCache.java
+++ b/fastbee-common/src/main/java/com/fastbee/common/core/redis/RedisCache.java
@@ -810,6 +810,32 @@ public class RedisCache {
return stringRedisTemplate.opsForValue().get(key);
}
+ /**
+ * 缓存基本的字符串
+ *
+ * 与 {@link #getStrCacheObject(String)} 配套。
+ * 走 StringRedisTemplate 而非 redisTemplate,避免 FastJson2 把值包成带引号的 JSON 字符串,
+ * 导致读回时多一层引号。
+ *
+ * @param key 缓存键值
+ * @param value 缓存值,不能为 null
+ */
+ public void setStrCacheObject(final String key, final String value) {
+ stringRedisTemplate.opsForValue().set(key, value);
+ }
+
+ /**
+ * 缓存基本的字符串并设置过期时间
+ *
+ * @param key 缓存键值
+ * @param value 缓存值,不能为 null
+ * @param timeout 时间
+ * @param timeUnit 时间颗粒度
+ */
+ public void setStrCacheObject(final String key, final String value, final Integer timeout, final TimeUnit timeUnit) {
+ stringRedisTemplate.opsForValue().set(key, value, timeout, timeUnit);
+ }
+
/**
* 删除单个对象
*
diff --git a/fastbee-iot-data/src/main/java/com/fastbee/iot/data/executor/PowerOnTriggerTaskExecutor.java b/fastbee-iot-data/src/main/java/com/fastbee/iot/data/executor/PowerOnTriggerTaskExecutor.java
index f4cbc188..3fff51e0 100644
--- a/fastbee-iot-data/src/main/java/com/fastbee/iot/data/executor/PowerOnTriggerTaskExecutor.java
+++ b/fastbee-iot-data/src/main/java/com/fastbee/iot/data/executor/PowerOnTriggerTaskExecutor.java
@@ -3,9 +3,11 @@ package com.fastbee.iot.data.executor;
import com.fastbee.base.service.ISessionStore;
import com.fastbee.base.session.Session;
import com.fastbee.common.constant.FastBeeConstant;
+import com.fastbee.common.constant.UserSettingConstants;
import com.fastbee.common.core.mq.message.DeviceMessage;
import com.fastbee.common.core.protocol.Message;
import com.fastbee.common.core.redis.RedisKeyBuilder;
+import com.fastbee.common.core.text.Convert;
import com.fastbee.common.utils.StringUtils;
import com.fastbee.common.utils.gateway.protocol.ByteUtils;
import com.fastbee.iot.data.service.IDeviceMessageService;
@@ -15,6 +17,7 @@ import com.fastbee.iot.domain.ModbusJob;
import com.fastbee.iot.enums.DeviceType;
import com.fastbee.iot.service.IDeviceService;
import com.fastbee.iot.service.IModbusJobService;
+import com.fastbee.system.service.ISysConfigService;
import io.netty.buffer.Unpooled;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
@@ -68,6 +71,9 @@ public class PowerOnTriggerTaskExecutor {
@Resource
private IDeviceService deviceService;
+ @Resource
+ private ISysConfigService sysConfigService;
+
/**
* 每台设备同一时刻仅允许一个"上电触发轮询"流水线运行,避免重复触发/并发下发导致应答错配
*/
@@ -90,8 +96,9 @@ public class PowerOnTriggerTaskExecutor {
//log.debug("设置上电触发标识,设备:{}", serialNumber);
// 2、下发IMEI/ICCID读取指令
- if (redisTemplate.opsForValue().get("get_imei_iccid") == null){
- log.warn("无缓存标识,无法下发IMEI/ICCID指令");
+ // 开关为全局配置:读该值的线程来自设备注册(@Async),无登录用户上下文,故不落用户设置表
+ if (!isReadImeiIccidEnabled()) {
+ log.info("IMEI/ICCID读取指令已关闭,跳过下发,设备编号:{}", serialNumber);
return;
}
Device device = deviceService.selectDeviceInfoBySerialNumber(serialNumber);
@@ -131,6 +138,22 @@ public class PowerOnTriggerTaskExecutor {
}
}
+ /**
+ * 读取「是否下发IMEI/ICCID指令」全局开关
+ *
+ *
配置缺失时按开启处理:设备出厂时 imei/iccid/经纬度均为空,
+ * 不下发则该字段永远无法填充,功能形同失效。
+ *
+ * @return true 下发,false 不下发
+ */
+ private boolean isReadImeiIccidEnabled() {
+ String configValue = sysConfigService.selectConfigByKey(UserSettingConstants.READ_IMEI_ICCID_KEY);
+ if (StringUtils.isEmpty(configValue)) {
+ return true;
+ }
+ return Convert.toBool(configValue);
+ }
+
/**
* 检查并消费上电触发标识(原子操作)
* @param serialNumber 设备编号
diff --git a/fastbee-open-api/src/main/java/com/fastbee/controller/device/DeviceController.java b/fastbee-open-api/src/main/java/com/fastbee/controller/device/DeviceController.java
index b05ba2b3..437a5d28 100644
--- a/fastbee-open-api/src/main/java/com/fastbee/controller/device/DeviceController.java
+++ b/fastbee-open-api/src/main/java/com/fastbee/controller/device/DeviceController.java
@@ -23,6 +23,9 @@ import com.fastbee.iot.model.SerialNumberVO;
import com.fastbee.iot.model.dto.ThingsModelDTO;
import com.fastbee.iot.model.gateWay.SubDeviceCreateByProductVO;
import com.fastbee.iot.service.IDeviceService;
+import com.fastbee.iot.utils.SerialNumberUtils;
+import com.fastbee.system.domain.SysUserSetting;
+import com.fastbee.system.service.ISysUserSettingService;
import com.github.pagehelper.PageInfo;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
@@ -56,6 +59,9 @@ public class DeviceController extends BaseController {
@Autowired
private RedisCache redisCache;
+ @Autowired
+ private ISysUserSettingService userSettingService;
+
/**
* 查询设备列表
*/
@@ -113,6 +119,16 @@ public class DeviceController extends BaseController {
public TableDataInfo shortList(Device device) {
startPage();
LoginUser loginUser = getLoginUser();
+ // 设备编号支持一次填多个(中英文逗号/换行/空格分隔),统一拆成去重后的 IN 查询集合;
+ // 单个编号同样得到 size=1 的集合走 IN;serialNumber 为空时集合为空,SQL 不加编号条件。
+ device.setSerialNumbers(SerialNumberUtils.split(device.getSerialNumber()));
+ if (Objects.isNull(device.getQueryGatewayChild())) {
+ // 未显式指定时,按当前登录用户的个人设置决定「按编号查询时是否查询网关子设备」:
+ // 命中子设备编号则返回其所属网关,并放开「网关子设备(4)」产品过滤(仅按编号查时)。
+ // 维度判据遵循「读该值时能否拿到 userId」:此处有 SecurityContext,故为 per-user 设置。
+ SysUserSetting userSetting = userSettingService.selectByUserId(loginUser.getUserId());
+ device.setQueryGatewayChild(userSetting != null && Boolean.TRUE.equals(userSetting.getQueryGatewayChild()));
+ }
if (null == loginUser.getDeptId()) {
// 终端用户查询设备
device.setTenantId(loginUser.getUserId());
@@ -143,7 +159,7 @@ public class DeviceController extends BaseController {
/**
* 查询树形设备列表(用于报表生成等场景,网关设备可展开显示子设备)
*/
- @PreAuthorize("@ss.hasAnyPermi('iot:device:list,iot:report:generate')")
+ @PreAuthorize("@ss.hasAnyPermi('iot:device:list,iot:report:generate,dataCenter:repair:list')")
@GetMapping("/treeList")
@ApiOperation("查询树形设备列表(网关可展开子设备)")
public AjaxResult treeList(Device device) {
diff --git a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/domain/Device.java b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/domain/Device.java
index 89eec527..c0d57142 100644
--- a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/domain/Device.java
+++ b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/domain/Device.java
@@ -79,6 +79,18 @@ public class Device extends BaseEntity {
@Excel(name = "设备编号")
private String serialNumber;
+ /**
+ * 设备编号集合(批量查询)
+ *
+ *
前端一次填入多个编号时,Controller 用 {@code SerialNumberUtils}
+ * 把 {@link #serialNumber} 拆到本字段,Mapper 走 IN 查询;
+ * 只有一个编号时不填充本字段,仍走 {@link #serialNumber} 的等值匹配。
+ */
+ @Transient
+ @ApiModelProperty("设备编号集合(批量查询用)")
+ @TableField(exist = false)
+ private List serialNumbers;
+
/**
* 基准点设备ID(结构安全监测用)
*
@@ -288,6 +300,11 @@ public class Device extends BaseEntity {
@TableField(exist = false)
private Boolean showChild;
+ @Transient
+ @ApiModelProperty("按编号查询时是否查询网关子设备:命中子设备编号返回其所属网关,并放开「网关子设备(4)」产品过滤(为空时由后端按当前用户的个人设置填充)")
+ @TableField(exist = false)
+ private Boolean queryGatewayChild;
+
@Transient
@ApiModelProperty("查询入口(1=设备管理列表(需要查看更新时间) 2=数据联动、项目管理、设备绑定 3=全部设备)")
@TableField(exist = false)
diff --git a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/platform/service/impl/PlatformDeviceMappingServiceImpl.java b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/platform/service/impl/PlatformDeviceMappingServiceImpl.java
index 7272aa7f..28056eb6 100644
--- a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/platform/service/impl/PlatformDeviceMappingServiceImpl.java
+++ b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/platform/service/impl/PlatformDeviceMappingServiceImpl.java
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.fastbee.iot.domain.Device;
import com.fastbee.iot.mapper.DeviceMapper;
import com.fastbee.iot.platform.domain.*;
+import com.fastbee.iot.utils.SerialNumberUtils;
import com.fastbee.iot.platform.flow.FlowContext;
import com.fastbee.iot.platform.flow.PlatformFlowExecutor;
import com.fastbee.iot.platform.mapper.PlatformDeviceMappingMapper;
@@ -51,16 +52,23 @@ public class PlatformDeviceMappingServiceImpl extends ServiceImpl selectList(PlatformDeviceMapping query) {
- String serialNumber = query.getSerialNumber();
+ // 设备编号支持一次填多个(中英文逗号/换行/空格分隔),逐个做模糊匹配后按 OR 组合。
+ // 保留原有 LIKE 语义(命中主设备编号或子设备编号),单值场景与改造前完全等价。
+ List serialNumbers = SerialNumberUtils.split(query.getSerialNumber());
LambdaQueryWrapper wrapper = Wrappers.lambdaQuery()
.eq(query.getPlatformId() != null, PlatformDeviceMapping::getPlatformId, query.getPlatformId())
.eq(query.getDeviceId() != null, PlatformDeviceMapping::getDeviceId, query.getDeviceId())
- .and(StringUtils.hasText(serialNumber), w -> w
- .like(PlatformDeviceMapping::getSerialNumber, serialNumber)
- .or()
- .apply("EXISTS (SELECT 1 FROM platform_device_sub_mapping s " +
- "WHERE s.parent_mapping_id = platform_device_mapping.id AND s.sub_serial_number LIKE {0})",
- "%" + serialNumber + "%"))
+ .and(!serialNumbers.isEmpty(), w -> {
+ for (int i = 0; i < serialNumbers.size(); i++) {
+ if (i > 0) {
+ w.or();
+ }
+ String pattern = "%" + serialNumbers.get(i) + "%";
+ w.apply("(platform_device_mapping.serial_number LIKE {0} OR EXISTS (SELECT 1 FROM platform_device_sub_mapping s "
+ + "WHERE s.parent_mapping_id = platform_device_mapping.id AND s.sub_serial_number LIKE {1}))",
+ pattern, pattern);
+ }
+ })
.eq(StringUtils.hasText(query.getSensorType()), PlatformDeviceMapping::getSensorType, query.getSensorType())
.eq(query.getNetStatus() != null, PlatformDeviceMapping::getNetStatus, query.getNetStatus())
.eq(StringUtils.hasText(query.getEnable()), PlatformDeviceMapping::getEnable, query.getEnable())
diff --git a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/DeviceServiceImpl.java b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/DeviceServiceImpl.java
index d9ec3eda..8ff18684 100644
--- a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/DeviceServiceImpl.java
+++ b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/DeviceServiceImpl.java
@@ -1026,24 +1026,12 @@ public class DeviceServiceImpl implements IDeviceService {
if (CollectionUtils.isEmpty(list)) {
return new ArrayList<>();
}
- List serialNumberList;
- SysUser user = getLoginUser().getUser();
- if (null != user.getDept()) {
- Long tenantId = user.getDept().getDeptUserId();
- serialNumberList = list.stream().filter(d -> tenantId.equals(d.getTenantId()))
- .map(DeviceShortOutput::getSerialNumber).collect(Collectors.toList());
- } else {
- serialNumberList = list.stream().map(DeviceShortOutput::getSerialNumber).collect(Collectors.toList());
+ Integer entrance = null == device ? null : device.getEntrance();
+ // 未处理告警数(alertCount)只有详情,根据产品查询设备详情 entrance=4
+ if (Integer.valueOf(4).equals(entrance)) {
+ fillAlertCount(list);
}
- if (!CollectionUtils.isEmpty(serialNumberList)) {
- List alist = alertLogService.selectDeviceAlertCount(serialNumberList);
- for (DeviceAlertCount item : alist) {
- list.stream()
- .filter(it -> Objects.equals(it.getSerialNumber(), item.getSerialNumber()))
- .forEach(it -> it.setAlertCount(item));
- }
- }
- fillShortListLatestReportTime(list, device.getEntrance());
+ fillShortListLatestReportTime(list, entrance);
long took = System.currentTimeMillis() - startTime;
if (took > 1000L) {
log.warn("selectDeviceShortList took: {}", took);
@@ -1051,6 +1039,39 @@ public class DeviceServiceImpl implements IDeviceService {
return list;
}
+ /**
+ * 回填设备未处理告警数(DeviceShortOutput#alertCount)。
+ *
+ * 只统计当前登录用户自己租户的设备:存在部门时按 dept.deptUserId 过滤,
+ * 因此「查询下级机构」带出来的下级机构设备不会带告警徽标。
+ */
+ private void fillAlertCount(List list) {
+ SysUser user = getLoginUser().getUser();
+ Long deptUserId = null != user.getDept() ? user.getDept().getDeptUserId() : null;
+ List serialNumberList = list.stream()
+ .filter(d -> null == deptUserId || deptUserId.equals(d.getTenantId()))
+ .map(DeviceShortOutput::getSerialNumber)
+ .collect(Collectors.toList());
+ if (CollectionUtils.isEmpty(serialNumberList)) {
+ return;
+ }
+ List alertCountList = alertLogService.selectDeviceAlertCount(serialNumberList);
+ if (CollectionUtils.isEmpty(alertCountList)) {
+ return;
+ }
+ // 先按 serialNumber 建索引再回填,避免原先的嵌套 stream 匹配(O(设备数 × 告警行数))
+ Map alertCountMap = new HashMap<>(alertCountList.size());
+ for (DeviceAlertCount item : alertCountList) {
+ alertCountMap.put(item.getSerialNumber(), item);
+ }
+ for (DeviceShortOutput row : list) {
+ DeviceAlertCount alertCount = alertCountMap.get(row.getSerialNumber());
+ if (null != alertCount) {
+ row.setAlertCount(alertCount);
+ }
+ }
+ }
+
/**
* 查询当前用户可见设备统计(与 shortList 相同范围)
*/
diff --git a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/utils/SerialNumberUtils.java b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/utils/SerialNumberUtils.java
new file mode 100644
index 00000000..8c3caad3
--- /dev/null
+++ b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/utils/SerialNumberUtils.java
@@ -0,0 +1,52 @@
+package com.fastbee.iot.utils;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * 设备编号批量输入解析工具
+ *
+ * 前端允许一次填入多个设备编号,分隔符支持中英文逗号、换行与空格
+ * (与批量输入弹框的提示文案一致)。此处统一归一化为去重后的编号集合,
+ * 供 Mapper 以 IN 查询使用;单个编号沿用原有的等值匹配分支,互不影响。
+ *
+ * @author Sumuxi
+ */
+public final class SerialNumberUtils {
+
+ /**
+ * 分隔符:英文逗号 / 中文逗号 / 任意空白(含换行、制表符)
+ *
+ *
用 + 合并连续分隔符,避免出现空串元素。
+ */
+ private static final Pattern SEPARATOR = Pattern.compile("[,,\\s]+");
+
+ private SerialNumberUtils() {
+ }
+
+ /**
+ * 拆分并归一化设备编号
+ *
+ * @param raw 原始输入,可为 null
+ * @return 去空、去重且保持输入顺序的编号列表;无有效值时返回空列表(非 null)
+ */
+ public static List split(String raw) {
+ List result = new ArrayList<>();
+ if (raw == null) {
+ return result;
+ }
+ // 用 LinkedHashSet 去重并保持输入顺序,避免重复编号导致 IN 列表冗余
+ Set distinct = new LinkedHashSet<>();
+ for (String item : SEPARATOR.split(raw)) {
+ String trimmed = item.trim();
+ if (!trimmed.isEmpty()) {
+ distinct.add(trimmed);
+ }
+ }
+ result.addAll(distinct);
+ return result;
+ }
+}
diff --git a/fastbee-service/fastbee-iot-service/src/main/resources/mapper/iot/DeviceMapper.xml b/fastbee-service/fastbee-iot-service/src/main/resources/mapper/iot/DeviceMapper.xml
index 87b6aaa1..c8994d15 100644
--- a/fastbee-service/fastbee-iot-service/src/main/resources/mapper/iot/DeviceMapper.xml
+++ b/fastbee-service/fastbee-iot-service/src/main/resources/mapper/iot/DeviceMapper.xml
@@ -428,7 +428,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
and d.gw_dev_code is null
- and (p.device_type is null or p.device_type != 4)
+
+
+ and (p.device_type is null or p.device_type != 4)
+
and g.group_id = #{groupId}
and d.device_name like concat('%', #{deviceName}, '%')
@@ -441,7 +449,33 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
and d.product_name like concat('%', #{productName}, '%')
- and d.serial_number = #{serialNumber}
+
+
+
+
+
+ and d.serial_number in (
+ select ifnull(sub.gw_dev_code, sub.serial_number)
+ from iot_device sub
+ where sub.serial_number in
+
+ #{serialNumberItem}
+
+ )
+
+
+ and d.serial_number in
+
+ #{serialNumberItem}
+
+
+
+
+ and d.serial_number = #{serialNumber}
and d.gw_dev_code = #{gwDevCode}
and d.status = #{status}
and d.is_simulate = #{isSimulate}
@@ -1027,7 +1061,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
and d.gw_dev_code is null
- and (p.device_type is null or p.device_type != 4)
+
+
+ and (p.device_type is null or p.device_type != 4)
+
and g.group_id = #{groupId}
and d.device_name like concat('%', #{deviceName}, '%')
@@ -1040,7 +1082,33 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
and d.product_name like concat('%', #{productName}, '%')
- and d.serial_number = #{serialNumber}
+
+
+
+
+
+ and d.serial_number in (
+ select ifnull(sub.gw_dev_code, sub.serial_number)
+ from iot_device sub
+ where sub.serial_number in
+
+ #{serialNumberItem}
+
+ )
+
+
+ and d.serial_number in
+
+ #{serialNumberItem}
+
+
+
+
+ and d.serial_number = #{serialNumber}
and d.gw_dev_code = #{gwDevCode}
and d.status = #{status}
and d.is_simulate = #{isSimulate}
diff --git a/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/LoginRedirectOption.java b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/LoginRedirectOption.java
new file mode 100644
index 00000000..f10531e9
--- /dev/null
+++ b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/LoginRedirectOption.java
@@ -0,0 +1,50 @@
+package com.fastbee.system.domain;
+
+import java.io.Serializable;
+
+/**
+ * 登录落地页候选项(系统菜单)
+ *
+ * 数据源为 {@code sys_menu} 中可作为落地页的页签菜单,供用户选择登录后进入的页面。
+ * 已剔除按钮(F)、停用(status=1)、隐藏(visible=1) 与外链(is_frame=0)菜单——
+ * 外链若进入本列表会成为开放重定向入口。
+ *
+ * @author Sumuxi
+ */
+public class LoginRedirectOption implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 完整路由 path,如 /iot/device
+ */
+ private String path;
+
+ /**
+ * 菜单名称
+ */
+ private String menuName;
+
+ public LoginRedirectOption() {
+ }
+
+ public LoginRedirectOption(String path, String menuName) {
+ this.path = path;
+ this.menuName = menuName;
+ }
+
+ public String getPath() {
+ return path;
+ }
+
+ public void setPath(String path) {
+ this.path = path;
+ }
+
+ public String getMenuName() {
+ return menuName;
+ }
+
+ public void setMenuName(String menuName) {
+ this.menuName = menuName;
+ }
+}
diff --git a/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/SysUserSetting.java b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/SysUserSetting.java
new file mode 100644
index 00000000..0bad61af
--- /dev/null
+++ b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/SysUserSetting.java
@@ -0,0 +1,157 @@
+package com.fastbee.system.domain;
+
+import com.fastbee.common.constant.UserSettingConstants;
+import com.fastbee.common.core.domain.BaseEntity;
+import io.swagger.annotations.ApiModelProperty;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.commons.lang3.builder.ToStringStyle;
+
+/**
+ * 用户系统设置对象 sys_user_setting
+ *
+ *
一行一用户(user_id 唯一)。所有设置列 NOT NULL + DEFAULT,
+ * 不存在「未配置过」的 NULL 态;业务默认值见 {@link UserSettingConstants}。
+ *
+ * @author Sumuxi
+ */
+public class SysUserSetting extends BaseEntity {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 设置主键
+ */
+ @ApiModelProperty(value = "设置主键")
+ private Long settingId;
+
+ /**
+ * 用户ID
+ */
+ @ApiModelProperty(value = "用户ID")
+ private Long userId;
+
+ /**
+ * 登录后跳转路由 path
+ */
+ @ApiModelProperty(value = "登录后跳转路由path")
+ private String loginRedirect;
+
+ /**
+ * 任务栏固定标签,JSON 数组字符串,按固定顺序存放 route path
+ *
+ *
只存用户右键「固定标签到任务栏」的 pinned 集合;
+ * 路由自身声明的 affix(如首页)由 constantRoutes 天然重建,不在此列。
+ * 空数组字符串 "[]" 表示未固定,不存 NULL。
+ */
+ @ApiModelProperty(value = "任务栏固定标签JSON数组")
+ private String pinnedTags;
+
+ /**
+ * 是否查询网关子设备
+ *
+ *
开启后,{@code /iot/device/shortList} 按设备编号查询时:
+ *
+ * - 命中的若是网关子设备编号,返回其所属的网关设备;
+ * - 放开产品类型为「网关子设备(4)」的过滤(这类设备 {@code gw_dev_code} 为空、实为独立设备,
+ * 只是产品被标成 4,默认隐藏,仅在按编号精确定位时才可见)。
+ *
+ * 列表范围本身不变:{@code gw_dev_code is null} 恒生效,不带编号时 {@code device_type != 4}
+ * 依然过滤,因此列表中永远不会出现「子设备」条目。
+ */
+ @ApiModelProperty(value = "是否查询网关子设备(按编号查时换算网关并放开子设备类产品过滤)")
+ private Boolean queryGatewayChild;
+
+ /**
+ * 设备管理列表是否包含下级机构数据
+ *
+ *
对应 {@code Device.showChild}:仅在机构用户(deptId 非空)时参与 SQL,
+ * 开启时 {@code tenant_id} 落在「本机构及全部下级机构用户」集合内,关闭时仅本机构。
+ * 原为设备管理页勾选框 + 浏览器 localStorage,现改为落库、由系统设置页维护;
+ * 设备管理页只读取该值作为查询参数下发,页面内不再可就地修改。
+ */
+ @ApiModelProperty(value = "设备管理列表是否包含下级机构数据")
+ private Boolean showChild;
+
+ public SysUserSetting() {
+ }
+
+ /**
+ * 新建一份带默认值的设置(用户首次保存时使用)
+ *
+ * @param userId 用户ID
+ * @return 默认设置对象
+ */
+ public static SysUserSetting defaultOf(Long userId) {
+ SysUserSetting setting = new SysUserSetting();
+ setting.setUserId(userId);
+ setting.setLoginRedirect(UserSettingConstants.DEFAULT_LOGIN_REDIRECT);
+ setting.setPinnedTags(UserSettingConstants.EMPTY_PINNED_TAGS);
+ setting.setQueryGatewayChild(UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD);
+ setting.setShowChild(UserSettingConstants.DEFAULT_SHOW_CHILD);
+ return setting;
+ }
+
+ public Long getSettingId() {
+ return settingId;
+ }
+
+ public void setSettingId(Long settingId) {
+ this.settingId = settingId;
+ }
+
+ public Long getUserId() {
+ return userId;
+ }
+
+ public void setUserId(Long userId) {
+ this.userId = userId;
+ }
+
+ public String getLoginRedirect() {
+ return loginRedirect;
+ }
+
+ public void setLoginRedirect(String loginRedirect) {
+ this.loginRedirect = loginRedirect;
+ }
+
+ public String getPinnedTags() {
+ return pinnedTags;
+ }
+
+ public void setPinnedTags(String pinnedTags) {
+ this.pinnedTags = pinnedTags;
+ }
+
+ public Boolean getQueryGatewayChild() {
+ return queryGatewayChild;
+ }
+
+ public void setQueryGatewayChild(Boolean queryGatewayChild) {
+ this.queryGatewayChild = queryGatewayChild;
+ }
+
+ public Boolean getShowChild() {
+ return showChild;
+ }
+
+ public void setShowChild(Boolean showChild) {
+ this.showChild = showChild;
+ }
+
+ @Override
+ public String toString() {
+ return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
+ .append("settingId", getSettingId())
+ .append("userId", getUserId())
+ .append("loginRedirect", getLoginRedirect())
+ .append("pinnedTags", getPinnedTags())
+ .append("queryGatewayChild", getQueryGatewayChild())
+ .append("showChild", getShowChild())
+ .append("createBy", getCreateBy())
+ .append("createTime", getCreateTime())
+ .append("updateBy", getUpdateBy())
+ .append("updateTime", getUpdateTime())
+ .append("remark", getRemark())
+ .toString();
+ }
+}
diff --git a/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/mapper/SysUserSettingMapper.java b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/mapper/SysUserSettingMapper.java
new file mode 100644
index 00000000..6af3d64c
--- /dev/null
+++ b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/mapper/SysUserSettingMapper.java
@@ -0,0 +1,88 @@
+package com.fastbee.system.mapper;
+
+import com.fastbee.system.domain.LoginRedirectOption;
+import com.fastbee.system.domain.SysUserSetting;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 用户系统设置 数据层
+ *
+ * @author Sumuxi
+ */
+public interface SysUserSettingMapper {
+
+ /**
+ * 按用户ID查询设置
+ *
+ * @param userId 用户ID
+ * @return 设置,不存在返回 null
+ */
+ SysUserSetting selectByUserId(Long userId);
+
+ /**
+ * 部分更新设置:只更新入参中**非 null** 的列,未传的列保持库中原值
+ *
+ *
调用方先 {@link #insertDefaultIfAbsent} 保证行存在,否则 UPDATE 0 行会静默丢改动。
+ * 刻意不提供「整体覆盖写」的入口:设置页每次只提交用户改动过的那几项,
+ * 整体覆盖会让「保存 A 配置」把 B 配置冲回默认值。
+ *
+ * @param setting 设置,userId 必填,其余字段只填本次要改的
+ * @return 影响行数
+ */
+ int updateSetting(SysUserSetting setting);
+
+ /**
+ * 仅更新固定标签列(整列覆盖)
+ *
+ *
只在 {@code addPinnedTag / removePinnedTag} 的「锁定读 → 改 → 写」事务内调用,
+ * 入参是**锁内读出并改好的完整数组**。对外不再提供「全量覆盖写」的入口——
+ * 那会让两个标签页各自拿旧快照互相覆盖,是丢固定标签的直接成因。
+ *
+ * @param setting 设置,userId 与 pinnedTags 必填
+ * @return 影响行数
+ */
+ int updatePinnedTags(SysUserSetting setting);
+
+ /**
+ * 确保用户设置行存在:不存在则插入默认行,已存在则不做任何修改
+ *
+ *
固定标签的增量写入要在一行上做「锁定读 → 改 → 写」,行必须先存在。
+ * 刻意不用 ON DUPLICATE 写其它列:那会覆盖
+ * login_redirect / query_gateway_child / show_child,把用户已保存的设置冲回默认值。
+ *
+ * @param userId 用户ID
+ * @param loginRedirect 新行使用的默认登录跳转
+ * @param queryGatewayChild 新行使用的默认开关值
+ * @param showChild 新行使用的默认「包含下级机构数据」值
+ * @param operator 操作人用户名
+ * @param now 当前时间
+ * @return 影响行数(插入 1 / 已存在 0)
+ */
+ int insertDefaultIfAbsent(@Param("userId") Long userId,
+ @Param("loginRedirect") String loginRedirect,
+ @Param("queryGatewayChild") Boolean queryGatewayChild,
+ @Param("showChild") Boolean showChild,
+ @Param("operator") String operator,
+ @Param("now") Date now);
+
+ /**
+ * 锁定读取固定标签列(SELECT ... FOR UPDATE)
+ *
+ *
必须在事务内调用。固定标签的写入是「读-改-写」,没有行锁时两个并发请求
+ * 会各自读到同一份旧值,后写的把先写的覆盖掉。
+ *
+ * @param userId 用户ID
+ * @return pinned_tags 原始 JSON 字符串;用户无记录时返回 null
+ */
+ String selectPinnedTagsForUpdate(@Param("userId") Long userId);
+
+ /**
+ * 查询登录落地页候选项(可作为页签的菜单)
+ *
+ * @return 候选项列表
+ */
+ List selectLoginRedirectOptions();
+}
diff --git a/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/ISysUserSettingService.java b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/ISysUserSettingService.java
new file mode 100644
index 00000000..a676e9c1
--- /dev/null
+++ b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/ISysUserSettingService.java
@@ -0,0 +1,78 @@
+package com.fastbee.system.service;
+
+import com.fastbee.system.domain.LoginRedirectOption;
+import com.fastbee.system.domain.SysUserSetting;
+
+import java.util.List;
+
+/**
+ * 用户系统设置 服务层
+ *
+ * 所有方法的 userId 均由调用方显式传入,Service 内部不读 SecurityUtils。
+ * 原因:设置读取方不只有 HTTP 请求线程(可拿到 SecurityContext),
+ * 还有设备上电等无登录上下文的线程,不能假设一定存在登录态。
+ *
+ * @author Sumuxi
+ */
+public interface ISysUserSettingService {
+
+ /**
+ * 查询用户设置;未保存过时返回带默认值的对象(不会返回 null)
+ *
+ * @param userId 用户ID
+ * @return 用户设置
+ */
+ SysUserSetting selectByUserId(Long userId);
+
+ /**
+ * 部分更新用户设置:只写 {@code patch} 中非 null 的字段,未传的字段保持库中原值
+ *
+ *
调用方每次只提交「用户真正改动过」的配置项,因此保存某一项不会把其它项冲成默认值。
+ * 用户还没有设置行时先落一行默认值再更新,避免 UPDATE 0 行静默丢改动。
+ *
+ * @param userId 用户ID
+ * @param patch 只填本次要改的字段,userId 字段忽略,以入参 userId 为准
+ * @param operator 操作人用户名,写入 update_by
+ * @return 影响行数
+ */
+ int updatePartial(Long userId, SysUserSetting patch, String operator);
+
+ /**
+ * 查询登录落地页候选项
+ *
+ * @return 候选项列表
+ */
+ List listLoginRedirectOptions();
+
+ /**
+ * 固定一个任务栏标签
+ *
+ * 增量写入:已固定则原样返回(幂等,**不改动它的位置**),未固定则追加到固定区末尾。
+ * 并发安全由服务端「锁定读 → 改 → 写」保证——多个标签页/多端同时固定不会互相覆盖,
+ * 因为前端不再提交自己那份可能过期的完整列表。
+ *
+ * @param userId 用户ID
+ * @param path 要固定的路由 path
+ * @param operator 操作人用户名,写入 update_by
+ */
+ void addPinnedTag(Long userId, String path, String operator);
+
+ /**
+ * 取消固定一个任务栏标签
+ *
+ *
增量写入:未固定则不做任何修改(幂等)。
+ *
+ * @param userId 用户ID
+ * @param path 要取消固定的路由 path
+ * @param operator 操作人用户名,写入 update_by
+ */
+ void removePinnedTag(Long userId, String path, String operator);
+
+ /**
+ * 查询任务栏固定标签
+ *
+ * @param userId 用户ID
+ * @return 固定标签的路由 path 列表,按固定顺序;未固定时返回空列表
+ */
+ List getPinnedTags(Long userId);
+}
diff --git a/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/impl/SysUserSettingServiceImpl.java b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/impl/SysUserSettingServiceImpl.java
new file mode 100644
index 00000000..c11f370a
--- /dev/null
+++ b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/impl/SysUserSettingServiceImpl.java
@@ -0,0 +1,268 @@
+package com.fastbee.system.service.impl;
+
+import com.alibaba.fastjson2.JSON;
+import com.baomidou.dynamic.datasource.annotation.DS;
+import com.fastbee.common.constant.CacheConstants;
+import com.fastbee.common.constant.UserSettingConstants;
+import com.fastbee.common.core.redis.RedisCache;
+import com.fastbee.common.utils.StringUtils;
+import com.fastbee.system.domain.LoginRedirectOption;
+import com.fastbee.system.domain.SysUserSetting;
+import com.fastbee.system.mapper.SysUserSettingMapper;
+import com.fastbee.system.service.ISysUserSettingService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 用户系统设置 服务层实现
+ *
+ * 读取策略:Redis 缓存未命中则查库并回填,未配置过则返回默认值对象。
+ * 缓存值为空表示「该用户还没有记录」,此时不写入缓存——
+ * 否则用户首次保存前,每次读都会穿透到数据库。
+ *
+ * @author Sumuxi
+ */
+@Slf4j
+@Service
+public class SysUserSettingServiceImpl implements ISysUserSettingService {
+
+ /**
+ * 缓存值的字段分隔符
+ *
+ *
用 \n 而不是常见的逗号/竖线:loginRedirect 是路由 path 不含换行,
+ * pinnedTags 是 JSON 数组(元素为带引号的字符串)也不含裸换行,
+ * showChild 与 queryGatewayChild 是布尔字面量,四段互不冲突。
+ *
字段顺序固定为:loginRedirect | pinnedTags | showChild | queryGatewayChild,
+ * 与 {@link #parseCached} 一一对应,增删字段必须同步改这两处。
+ */
+ private static final String CACHE_SEPARATOR = "\n";
+
+ @Autowired
+ private SysUserSettingMapper userSettingMapper;
+
+ @Autowired
+ private RedisCache redisCache;
+
+ @Override
+ @DS("master")
+ public SysUserSetting selectByUserId(Long userId) {
+ if (userId == null) {
+ return SysUserSetting.defaultOf(null);
+ }
+ String cacheKey = getCacheKey(userId);
+ // 缓存的是扁平字符串而非整个 POJO,理由同 SysConfigServiceImpl#selectConfigByKey:
+ // 本项目 Redis 序列化器是 FastJson2 + SupportAutoType,直接缓存继承 BaseEntity
+ // (含 Map params 等字段)的对象,反序列化存在取值不稳与
+ // autoType 风险;缓存扁平字段可完全规避。
+ String cached = redisCache.getStrCacheObject(cacheKey);
+ if (StringUtils.isNotEmpty(cached)) {
+ SysUserSetting fromCache = parseCached(cached, userId);
+ if (fromCache != null) {
+ return fromCache;
+ }
+ }
+ SysUserSetting setting = userSettingMapper.selectByUserId(userId);
+ if (setting == null) {
+ return SysUserSetting.defaultOf(userId);
+ }
+ writeCache(cacheKey, setting);
+ return setting;
+ }
+
+ /**
+ * 把设置刷入缓存
+ */
+ private void writeCache(String cacheKey, SysUserSetting setting) {
+ redisCache.setStrCacheObject(cacheKey,
+ setting.getLoginRedirect() + CACHE_SEPARATOR
+ + setting.getPinnedTags() + CACHE_SEPARATOR
+ + setting.getShowChild() + CACHE_SEPARATOR
+ + setting.getQueryGatewayChild());
+ }
+
+ /**
+ * 解析缓存值,格式非法时返回 null 走查库
+ *
+ * 段数必须严格等于 4:历史版本只缓存了 2 段(loginRedirect + pinnedTags)、
+ * 后又扩到 3 段(追加 queryGatewayChild)。若按缺省值补齐,会把旧格式里
+ * 那个位置的字段当成 showChild 读出来,故段数不等一律判为未命中,由查库回填新格式。
+ * 缓存无 TTL,但每个用户最多回填一次。
+ *
+ * @param cached 缓存字符串
+ * @param userId 用户ID
+ * @return 设置对象,解析失败返回 null
+ */
+ private SysUserSetting parseCached(String cached, Long userId) {
+ String[] parts = cached.split(CACHE_SEPARATOR, -1);
+ if (parts.length != 4) {
+ return null;
+ }
+ SysUserSetting setting = SysUserSetting.defaultOf(userId);
+ setting.setLoginRedirect(parts[0]);
+ setting.setPinnedTags(parts[1]);
+ setting.setShowChild(Boolean.parseBoolean(parts[2]));
+ setting.setQueryGatewayChild(Boolean.parseBoolean(parts[3]));
+ return setting;
+ }
+
+ @Override
+ @DS("master")
+ @Transactional(rollbackFor = Exception.class)
+ public int updatePartial(Long userId, SysUserSetting patch, String operator) {
+ Date now = new Date();
+ // 行必须先存在,否则后面的部分更新会 UPDATE 0 行、静默丢掉本次改动
+ userSettingMapper.insertDefaultIfAbsent(userId, UserSettingConstants.DEFAULT_LOGIN_REDIRECT,
+ UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD, UserSettingConstants.DEFAULT_SHOW_CHILD,
+ operator, now);
+ patch.setUserId(userId);
+ patch.setUpdateBy(operator);
+ patch.setUpdateTime(now);
+ int rows = userSettingMapper.updateSetting(patch);
+ // 写库成功后重查库再回填缓存:patch 里只有本次改动的字段,
+ // 直接拿它刷缓存会把未改动字段写成 null。
+ refreshCache(userId, patch);
+ return rows;
+ }
+
+ @Override
+ @DS("master")
+ @Transactional(rollbackFor = Exception.class)
+ public void addPinnedTag(Long userId, String path, String operator) {
+ if (!isValidPinnedPath(path)) {
+ return;
+ }
+ Date now = new Date();
+ // 行必须先存在,后面的锁定读才有东西可锁
+ userSettingMapper.insertDefaultIfAbsent(userId, UserSettingConstants.DEFAULT_LOGIN_REDIRECT,
+ UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD, UserSettingConstants.DEFAULT_SHOW_CHILD,
+ operator, now);
+ List paths = parsePinnedTags(userSettingMapper.selectPinnedTagsForUpdate(userId));
+ if (paths.contains(path)) {
+ // 已固定:幂等返回。不重复追加,也不把它挪到末尾,避免重复点击改变固定区顺序
+ return;
+ }
+ paths.add(path);
+ writePinnedTags(userId, paths, operator, now);
+ }
+
+ @Override
+ @DS("master")
+ @Transactional(rollbackFor = Exception.class)
+ public void removePinnedTag(Long userId, String path, String operator) {
+ if (!isValidPinnedPath(path)) {
+ return;
+ }
+ // 刻意不先建行:没有记录 = 本来就没固定过任何标签,
+ // 取消固定不该凭空给用户创建一条设置记录
+ List paths = parsePinnedTags(userSettingMapper.selectPinnedTagsForUpdate(userId));
+ if (!paths.remove(path)) {
+ // 没固定过:幂等返回
+ return;
+ }
+ writePinnedTags(userId, paths, operator, new Date());
+ }
+
+ /**
+ * 把锁定读出来的数组写回并刷新缓存
+ *
+ * 必须在 {@code selectPinnedTagsForUpdate} 之后、同一事务内调用:
+ * 行锁只有握到事务提交才释放,提前提交会让并发请求读到中间态。
+ *
+ * @param userId 用户ID
+ * @param paths 锁内读出并改好的完整列表
+ * @param operator 操作人用户名
+ * @param now 当前时间
+ */
+ private void writePinnedTags(Long userId, List paths, String operator, Date now) {
+ SysUserSetting patch = SysUserSetting.defaultOf(userId);
+ patch.setPinnedTags(JSON.toJSONString(paths));
+ patch.setUpdateBy(operator);
+ patch.setUpdateTime(now);
+ userSettingMapper.updatePinnedTags(patch);
+ refreshCache(userId, patch);
+ }
+
+ /**
+ * 重查一次并回填缓存,保证缓存与库内最终状态一致
+ *
+ * @param userId 用户ID
+ * @param fallback 重查返回 null 时的兜底对象(正常不会发生)
+ */
+ private void refreshCache(Long userId, SysUserSetting fallback) {
+ SysUserSetting latest = userSettingMapper.selectByUserId(userId);
+ writeCache(getCacheKey(userId), latest == null ? fallback : latest);
+ }
+
+ @Override
+ public List getPinnedTags(Long userId) {
+ return parsePinnedTags(selectByUserId(userId).getPinnedTags());
+ }
+
+ /**
+ * 校验固定标签 path 是否合法
+ *
+ * 只接受站内绝对路径,且排除协议相对地址({@code //host})、
+ * 带协议头的地址与路径穿越。path 最终会进 router.push,混入外链会变成开放重定向入口。
+ *
+ * @param path 待校验的路由 path
+ * @return 合法返回 true
+ */
+ private boolean isValidPinnedPath(String path) {
+ if (StringUtils.isEmpty(path) || !path.startsWith("/")) {
+ return false;
+ }
+ return !path.startsWith("//") && !path.contains("://") && !path.contains("..");
+ }
+
+ /**
+ * 解析固定标签,脏数据降级为空列表
+ *
+ *
列是 TEXT,允许人工改过;解析失败不应让整个设置页报错。
+ *
+ * @param json JSON 数组字符串
+ * @return path 列表
+ */
+ private List parsePinnedTags(String json) {
+ if (StringUtils.isEmpty(json)) {
+ return new ArrayList<>();
+ }
+ try {
+ List list = JSON.parseArray(json, String.class);
+ return list == null ? new ArrayList<>() : list;
+ } catch (Exception e) {
+ log.warn("解析 pinned_tags 失败,降级为空列表,原始值:{}", json);
+ return new ArrayList<>();
+ }
+ }
+
+ @Override
+ @DS("master")
+ public List listLoginRedirectOptions() {
+ List options = new ArrayList<>();
+ // 内置固定路由优先:/index 与 /bigscreen 由前端 constantRoutes 注册,不在 sys_menu 中
+ for (UserSettingConstants.BuiltinRedirect builtin : UserSettingConstants.BUILTIN_REDIRECTS) {
+ options.add(new LoginRedirectOption(builtin.getPath(), builtin.getName()));
+ }
+ List menuOptions = userSettingMapper.selectLoginRedirectOptions();
+ if (menuOptions != null && !menuOptions.isEmpty()) {
+ for (LoginRedirectOption option : menuOptions) {
+ // 菜单若与内置路由同名路径,以后者为准并去重,避免下拉出现重复项
+ boolean duplicated = options.stream().anyMatch(o -> o.getPath().equals(option.getPath()));
+ if (!duplicated) {
+ options.add(option);
+ }
+ }
+ }
+ return options;
+ }
+
+ private String getCacheKey(Long userId) {
+ return CacheConstants.SYS_USER_SETTING_KEY + userId;
+ }
+}
diff --git a/fastbee-service/fastbee-system-service/src/main/resources/mapper/system/SysUserSettingMapper.xml b/fastbee-service/fastbee-system-service/src/main/resources/mapper/system/SysUserSettingMapper.xml
new file mode 100644
index 00000000..24d04ec4
--- /dev/null
+++ b/fastbee-service/fastbee-system-service/src/main/resources/mapper/system/SysUserSettingMapper.xml
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ select setting_id, user_id, login_redirect, pinned_tags, query_gateway_child, show_child,
+ create_by, create_time, update_by, update_time, remark
+ from sys_user_setting
+
+
+
+
+
+
+ update sys_user_setting
+
+ login_redirect = #{loginRedirect},
+ query_gateway_child = #{queryGatewayChild},
+ show_child = #{showChild},
+ update_by = #{updateBy},
+ update_time = #{updateTime}
+
+ where user_id = #{userId}
+
+
+
+
+ insert into sys_user_setting
+ (user_id, login_redirect, pinned_tags, query_gateway_child, show_child, create_by, create_time, update_by, update_time)
+ values
+ (#{userId}, #{loginRedirect}, '[]', #{queryGatewayChild}, #{showChild}, #{operator}, #{now}, #{operator}, #{now})
+ on duplicate key update
+ user_id = user_id
+
+
+
+
+
+
+
+ update sys_user_setting
+ set pinned_tags = #{pinnedTags},
+ update_by = #{updateBy},
+ update_time = #{updateTime}
+ where user_id = #{userId}
+
+
+
+
+
+
diff --git a/sql/sys_user_setting.sql b/sql/sys_user_setting.sql
new file mode 100644
index 00000000..4918599f
--- /dev/null
+++ b/sql/sys_user_setting.sql
@@ -0,0 +1,116 @@
+-- =============================================
+-- 用户系统设置(用户维度)+ 设备侧全局开关入库
+-- 维度判据:读的时候拿不拿得到 userId
+-- · 拿得到(HTTP 请求,有 SecurityContext)→ sys_user_setting,per-user
+-- · 拿不到(设备上电 @Async 线程)→ sys_config,全局
+-- 配套代码:SysUserSetting* / PowerOnTriggerTaskExecutor:93
+-- 版本: 1.0 2026-10-05
+-- =============================================
+
+-- ------------------------------------------------------------
+-- 一、用户维度设置表
+-- 一行一用户;所有设置列 NOT NULL + DEFAULT,不设「未配置过」这种 NULL 态
+-- 业务默认值由 Java 常量兜底(UserSettingConstants),不依赖 DB 兜底
+--
+-- pinned_tags:任务栏「固定标签到任务栏」的 JSON 数组,按固定顺序存 route path。
+-- * 原实现存浏览器 localStorage(key: pinned-tags),换浏览器/换电脑就丢,
+-- 现改为落库,同一账号在任何设备登录都能恢复固定区。
+-- * 只存用户右键固定的 pinned 集合;路由自身声明的 affix(首页,router/index.js:80)
+-- 由 constantRoutes 天然重建,不入此列。
+-- * **写入语义:单条增量**。接口是 POST/DELETE 一个 path,服务端在事务里
+-- 「SELECT ... FOR UPDATE → 读数组 → 改 → UPDATE 整列」。
+-- 刻意**不提供提交整份列表的覆盖写接口**:前端手里那份列表可能已被
+-- 另一个标签页/另一台设备改过,整份提交会把对方的改动静默抹掉,
+-- 这正是固定标签「用着用着就少了」的成因。
+-- * 增删都在 Java 侧按列表处理,**未使用任何 MySQL JSON 函数**。
+-- * DEFAULT ('[]') 是 MySQL 8.0.13+ 的表达式默认值。TEXT 列不能带字面量 DEFAULT,
+-- 不给默认值时 STRICT_TRANS_TABLES 下会直接报错,故必须显式写空数组。
+-- 低版本数据库请改为 VARCHAR(2000) NOT NULL DEFAULT '[]'。
+--
+-- 已建过表的库用下面的 ALTER 补列(可重复执行前请先确认列不存在):
+-- ALTER TABLE `sys_user_setting`
+-- ADD COLUMN `pinned_tags` text NOT NULL DEFAULT ('[]')
+-- COMMENT '任务栏固定标签JSON数组,按序存 path;空数组表示未固定'
+-- AFTER `login_redirect`;
+-- ALTER TABLE `sys_user_setting`
+-- ADD COLUMN `query_gateway_child` tinyint(1) NOT NULL DEFAULT 0
+-- COMMENT '按编号查询时是否查网关子设备(0否1是)'
+-- AFTER `pinned_tags`;
+-- ALTER TABLE `sys_user_setting`
+-- ADD COLUMN `show_child` tinyint(1) NOT NULL DEFAULT 0
+-- COMMENT '设备管理列表是否包含下级机构数据(0否1是)'
+-- AFTER `query_gateway_child`;
+--
+-- query_gateway_child:开启后 /iot/device/shortList **按设备编号查询时**(serialNumber 非空):
+-- ① 命中的若是网关子设备编号,返回其所属的网关设备(子设备 gw_dev_code 存的就是网关 serial_number,
+-- 取 ifnull(gw_dev_code, serial_number) 换算);
+-- ② 放开产品类型为「网关子设备(4)」的过滤 —— 这类设备 gw_dev_code 为空、实为独立设备,
+-- 只是产品被标成 4,默认在列表中被隐藏(否则设备管理页会多出一大批),仅在按编号精确定位时可见。
+-- 该开关**不改变列表范围** —— gw_dev_code is null 恒生效,不带编号时 device_type != 4 依然过滤,
+-- 所以短列表始终只出顶层设备(网关 + 直连),既不出现挂在网关下的真子设备,
+-- 默认列表也不会多出 device_type=4 的设备。
+-- 默认 0。读该值的入口是 DeviceController#shortList,有 SecurityContext,故为 per-user 设置。
+--
+-- show_child:设备管理列表是否包含下级机构数据(原为设备管理页勾选框,值存在浏览器
+-- localStorage 的 device-showChild,换浏览器/换电脑就丢,现改为落库)。
+-- 生效条件同 Device.showChild:仅在 deptId 非空(机构用户)时参与 SQL,语义见
+-- DeviceMapper.xml#selectDeviceShortListJoinWhere —— 开启时 tenant_id 落在「本机构
+-- 及全部下级机构用户」集合内,关闭时仅本机构。
+-- 该值现在由「系统管理 → 系统设置」页维护,设备管理页只读不写(页面内的勾选框已移除)。
+-- 设备管理页进入时读一次该设置并作为查询参数下发,故本列是「前端读取、后端不感知」的偏好,
+-- DeviceController 在未收到 showChild 时依然按 false 处理。
+-- 默认 0 = 与引入本开关前完全一致。
+-- ------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `sys_user_setting` (
+ `setting_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '设置主键',
+ `user_id` bigint(20) NOT NULL COMMENT '用户ID(sys_user.user_id)',
+ `login_redirect` varchar(128) NOT NULL DEFAULT '/bigscreen' COMMENT '登录后跳转路由path',
+ `pinned_tags` text NOT NULL DEFAULT ('[]') COMMENT '任务栏固定标签JSON数组,按序存 path;空数组表示未固定',
+ `query_gateway_child` tinyint(1) NOT NULL DEFAULT 0 COMMENT '按编号查询时是否查网关子设备(0否1是)',
+ `show_child` tinyint(1) NOT NULL DEFAULT 0 COMMENT '设备管理列表是否包含下级机构数据(0否1是)',
+ `create_by` varchar(64) DEFAULT NULL COMMENT '创建者',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_by` varchar(64) DEFAULT NULL COMMENT '更新者',
+ `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+ `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+ PRIMARY KEY (`setting_id`) USING BTREE,
+ UNIQUE KEY `uk_sys_user_setting_user` (`user_id`) USING BTREE COMMENT '每用户仅一行'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
+ ROW_FORMAT=DYNAMIC COMMENT='用户系统设置表';
+
+-- ------------------------------------------------------------
+-- 二、设备侧全局开关 → 复用已有 sys_config 表
+-- 原实现:PowerOnTriggerTaskExecutor 直读运维手动写入的裸 Redis key
+-- "get_imei_iccid"(经 BackendController /set 写入)
+-- 新实现:改读 sys_config,运维改用已有的 PUT /system/config
+-- config_type='Y' = 系统内置,deleteConfigByIds 会拒删
+-- ------------------------------------------------------------
+INSERT INTO `sys_config`
+ (`config_name`, `config_key`, `config_value`, `config_type`, `create_by`, `create_time`, `remark`)
+SELECT '是否下发读取设备IMEI/ICCID指令', 'iot.power_on.read_imei_iccid', 'true', 'Y', 'system', NOW(),
+ '设备上电后是否下发 AT+IMEI/AT+ICCID/AT+LBS 指令读取;读该值的线程无登录上下文,故为全局配置'
+WHERE NOT EXISTS (
+ SELECT 1 FROM `sys_config` WHERE `config_key` = 'iot.power_on.read_imei_iccid'
+);
+
+-- ------------------------------------------------------------
+-- 三、菜单:系统管理 → 个人设置
+-- 不设 system:setting:list 权限点的原因:
+-- 接口读写的是「当前登录用户自己的」设置,无需菜单级授权;
+-- 若填了权限点,非管理员账号将看不到也用不了自己的设置页。
+-- menu_type='C' 菜单,component 指向新建的 views/system/setting/index.vue
+-- ------------------------------------------------------------
+INSERT INTO `sys_menu`
+ (`menu_name`, `parent_id`, `order_num`, `path`, `component`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`)
+SELECT '系统设置', 1, 13, 'setting', 'system/setting/index', 1, 0, 'C', '0', '0', '', 'setting', 'system', NOW()
+WHERE NOT EXISTS (
+ SELECT 1 FROM `sys_menu` WHERE `parent_id` = 1 AND `path` = 'setting'
+);
+
+-- ------------------------------------------------------------
+-- 四、清理历史遗留的裸 Redis key
+-- 原开关 key 无前缀、不在任何 key 生成器中,与 RedisKeyBuilder 规范不符。
+-- 确认线上设备已完成一轮上电、开关已由 sys_config 接管后再执行:
+-- redis-cli DEL get_imei_iccid
+-- 切勿在切换完成前执行,否则 IMEI/ICCID 指令会立即停止下发。
+-- ------------------------------------------------------------
diff --git a/yack-web/src/api/system/userSetting.js b/yack-web/src/api/system/userSetting.js
new file mode 100644
index 00000000..eacbf27a
--- /dev/null
+++ b/yack-web/src/api/system/userSetting.js
@@ -0,0 +1,56 @@
+import request from '@/utils/request'
+
+// 获取当前用户的设置
+export function getUserSetting() {
+ return request({
+ url: '/system/userSetting',
+ method: 'get'
+ })
+}
+
+// 部分更新当前用户的设置:data 里只放**本次改动过**的字段,未改动的一律不放
+// 用 PATCH 而非 PUT:服务端只按非空字段逐列更新,未传的字段保持库中原值,
+// 这样保存某一项配置不会把其它配置冲回默认值
+export function updateUserSetting(data) {
+ return request({
+ url: '/system/userSetting',
+ method: 'patch',
+ data: data
+ })
+}
+
+// 获取登录落地页候选项(取自系统菜单)
+export function listLoginRedirectOptions() {
+ return request({
+ url: '/system/userSetting/loginRedirectOptions',
+ method: 'get'
+ })
+}
+
+// 获取当前用户固定的任务栏标签
+export function getPinnedTags() {
+ return request({
+ url: '/system/userSetting/pinnedTags',
+ method: 'get'
+ })
+}
+
+// 固定一个任务栏标签(服务端幂等追加到固定区末尾)
+// 刻意不做「整份列表覆盖写」:前端持有的列表可能已被别的标签页改过,
+// 整份提交会把对方的改动静默抹掉
+export function addPinnedTag(path) {
+ return request({
+ url: '/system/userSetting/pinnedTags',
+ method: 'post',
+ params: { path }
+ })
+}
+
+// 取消固定一个任务栏标签(服务端幂等移除)
+export function removePinnedTag(path) {
+ return request({
+ url: '/system/userSetting/pinnedTags',
+ method: 'delete',
+ params: { path }
+ })
+}
diff --git a/yack-web/src/lang/en-US/product.json b/yack-web/src/lang/en-US/product.json
index c5a255a5..a3334347 100644
--- a/yack-web/src/lang/en-US/product.json
+++ b/yack-web/src/lang/en-US/product.json
@@ -426,11 +426,6 @@
"product.product-modbus.562372-44": "Add a single data register",
"product.product-modbus.562372-45": "Add multiple data registers",
"product.product-modbus.562372-46": "The default address cannot be empty",
- "product.product-modbus.562372-47": "Register address cannot be empty",
- "product.product-modbus.562372-48": "Read only (0- No, 1- Yes) cannot be empty",
- "product.product-modbus.562372-49": "Modbus data type cannot be empty",
- "product.product-modbus.562372-50": "The number of reads cannot be empty",
- "product.product-modbus.562372-51": "Register type 1-IO register 2-data register cannot be empty",
"product.product-modbus.562372-52": "Save successful",
"product.product-modbus.562372-53": "Modified successfully",
"product.product-modbus.562372-311": "Positional offset",
@@ -551,5 +546,21 @@
"product.product-things-model.142341-200": "Click symbols to build the formula",
"product.product-things-model.142341-201": "Reference product model identifiers",
"product.product-things-model.142341-202": "No available identifiers",
- "product.product-things-model.142341-203": "Add Calculated Model"
+ "product.product-things-model.142341-203": "Add Calculated Model",
+ "product.product-modbus.562372-320": "Batch",
+ "product.product-modbus.562372-321": "Scope: all {n} rows on this page",
+ "product.product-modbus.562372-323": "{n} rows updated",
+ "product.product-modbus.562372-325": "skipped {m} rows (data type mismatch)",
+ "product.product-modbus.562372-326": "Batch delete",
+ "product.product-modbus.562372-327": "Select rows to delete first",
+ "product.product-modbus.562372-328": "The {n} selected rows ({m} already saved) will be removed locally and take effect after you click Save. Continue?",
+ "product.product-modbus.562372-329": "1 (16-bit)",
+ "product.product-modbus.562372-330": "2 (32-bit)",
+ "product.product-modbus.562372-331": "Click \"Edit registers\" to enter edit mode first",
+ "product.product-modbus.562372-333": "Scope: {n} selected rows",
+ "product.product-modbus.562372-334": "Advances by read count · Enter or blur to generate",
+ "product.product-modbus.562372-335": "Generate",
+ "product.product-modbus.562372-336": "Generated {n} addresses: {from} -> {to}",
+ "product.product-modbus.562372-337": "{m} duplicate slave + register address pairs on this page",
+ "product.product-modbus.562372-338": "Product is published; click \"Unpublish\" first to edit registers"
}
diff --git a/yack-web/src/lang/zh-CN/product.json b/yack-web/src/lang/zh-CN/product.json
index e9be7ae1..12af98fd 100644
--- a/yack-web/src/lang/zh-CN/product.json
+++ b/yack-web/src/lang/zh-CN/product.json
@@ -426,11 +426,6 @@
"product.product-modbus.562372-44": "添加单个数据寄存器",
"product.product-modbus.562372-45": "添加多个数据寄存器",
"product.product-modbus.562372-46": "默认地址不能为空",
- "product.product-modbus.562372-47": "寄存器地址不能为空",
- "product.product-modbus.562372-48": "是否只读(0-否,1-是)不能为空",
- "product.product-modbus.562372-49": "modbus数据类型不能为空",
- "product.product-modbus.562372-50": "读取个数不能为空",
- "product.product-modbus.562372-51": "寄存器类型 1-IO寄存器 2-数据寄存器不能为空",
"product.product-modbus.562372-52": "保存成功",
"product.product-modbus.562372-53": "修改成功",
"product.product-modbus.562372-311": "位偏移量",
@@ -551,5 +546,21 @@
"product.product-things-model.142341-200": "点击符号快速拼接",
"product.product-things-model.142341-201": "引用产品物模型标识",
"product.product-things-model.142341-202": "暂无可用标识",
- "product.product-things-model.142341-203": "新增计算模型"
+ "product.product-things-model.142341-203": "新增计算模型",
+ "product.product-modbus.562372-320": "批量",
+ "product.product-modbus.562372-321": "作用:本页 {n} 行",
+ "product.product-modbus.562372-323": "已应用 {n} 行",
+ "product.product-modbus.562372-325": "跳过 {m} 行(数据类型不匹配)",
+ "product.product-modbus.562372-326": "批量删除",
+ "product.product-modbus.562372-327": "请先勾选要删除的行",
+ "product.product-modbus.562372-328": "将删除勾选的 {n} 行(含已保存 {m} 行),确认后需点击保存生效",
+ "product.product-modbus.562372-329": "1(16位)",
+ "product.product-modbus.562372-330": "2(32位)",
+ "product.product-modbus.562372-331": "请先点击「编辑寄存器」进入编辑状态",
+ "product.product-modbus.562372-333": "作用:已勾选 {n} 行",
+ "product.product-modbus.562372-334": "按读取个数递增 · 回车或失焦即生成",
+ "product.product-modbus.562372-335": "一键生成",
+ "product.product-modbus.562372-336": "已生成 {n} 行地址:{from} → {to}",
+ "product.product-modbus.562372-337": "本页有 {m} 处「从机地址 + 寄存器地址」重复,请检查",
+ "product.product-modbus.562372-338": "产品当前为已发布状态,请先点击「取消发布」后再编辑寄存器"
}
diff --git a/yack-web/src/layout/components/TagsView/index.vue b/yack-web/src/layout/components/TagsView/index.vue
index 4b33af48..835ae092 100644
--- a/yack-web/src/layout/components/TagsView/index.vue
+++ b/yack-web/src/layout/components/TagsView/index.vue
@@ -77,6 +77,8 @@
+
+
diff --git a/yack-web/src/views/dataCenter/dataRepair.vue b/yack-web/src/views/dataCenter/dataRepair.vue
index 05e10874..f8e53789 100644
--- a/yack-web/src/views/dataCenter/dataRepair.vue
+++ b/yack-web/src/views/dataCenter/dataRepair.vue
@@ -47,16 +47,34 @@
+
+
+
+
+
+
@@ -124,7 +142,7 @@
-
+
+
+
+
+
+
@@ -337,7 +372,7 @@
+
+
diff --git a/yack-web/src/views/iot/product/modbus-derive.js b/yack-web/src/views/iot/product/modbus-derive.js
new file mode 100644
index 00000000..f47f3818
--- /dev/null
+++ b/yack-web/src/views/iot/product/modbus-derive.js
@@ -0,0 +1,44 @@
+/**
+ * modbus 配置派生规则(唯一口径,页面与批量面板共用)
+ *
+ * 红线:`读取个数(quantity)` 是 `数据类型(dataType)` 的派生量,不是独立字段。
+ * 后端 ModbusProtocol#handleRead0304 完全按 quantity 分支解析:
+ * quantity==1 → 16 位(CalculateUtils.handleToUnSign16)
+ * quantity==2 → 32 位(CalculateUtils.parseValue)
+ * 其它值 → log.warn 直接丢弃
+ * 字典 iot_modbus_data_type(sql/yack-init.sql:2503-2511):
+ * bit=1 位;ushort/short=16 位 1 个寄存器;long/ulong/float 的 ABCD/CDAB 变体=32 位 2 个寄存器。
+ */
+
+/** 32 位数据类型集合(占 2 个寄存器) */
+export const MODBUS_32BIT_TYPES = ['long-ABCD', 'long-CDAB', 'ulong-ABCD', 'ulong-CDAB', 'float-ABCD', 'float-CDAB'];
+
+/** 16 位数据类型集合(占 1 个寄存器) */
+export const MODBUS_16BIT_TYPES = ['ushort', 'short'];
+
+/**
+ * 由数据类型推导读取个数
+ * @param {string} dataType 字典 iot_modbus_data_type 的值
+ * @returns {number|null} 1 | 2;dataType 为空或未知类型返回 null(不派生,避免把 data_type 为空的历史行改坏)
+ */
+export function deriveQuantity(dataType) {
+ if (!dataType) return null;
+ if (dataType === 'bit') return 1;
+ if (MODBUS_16BIT_TYPES.indexOf(dataType) !== -1) return 1;
+ if (MODBUS_32BIT_TYPES.indexOf(dataType) !== -1) return 2;
+ return null;
+}
+
+/**
+ * 单行占用的寄存器个数(一 key 生成地址序列时的步长)
+ * dataType 非空 → 以派生值为准(权威);dataType 为空的历史行 → 回退行内 quantity;再缺省 1
+ * @param {object} row modbus 配置行
+ * @returns {number} 1 | 2 | 行内 quantity
+ */
+export function resolveQuantity(row) {
+ if (!row) return 1;
+ const derived = deriveQuantity(row.dataType);
+ if (derived !== null) return derived;
+ const quantity = Number(row.quantity);
+ return quantity > 0 ? quantity : 1;
+}
diff --git a/yack-web/src/views/iot/product/product-modbus.vue b/yack-web/src/views/iot/product/product-modbus.vue
index c297f742..f740c88a 100644
--- a/yack-web/src/views/iot/product/product-modbus.vue
+++ b/yack-web/src/views/iot/product/product-modbus.vue
@@ -93,7 +93,8 @@
-
+
+
@@ -102,15 +103,13 @@
:disabled="!enableEditIO"
v-model="scope.row.identifier"
:placeholder="$t('product.product-modbus.562372-26')"
- :ref="`selectIo${scope.$index}`"
size="mini"
- @change="(e) => updateSelectThingsModel({ newVal: e, oldVal: $refs[`selectIo${scope.$index}`].value, justiceSelect: 'isSelectIo' })"
>
{{ $t('product.product-modbus.562372-27') }}
{{ $t('product.product-modbus.562372-28') }}
-
+
{{ item.modelName }}
{{ item.identifier }}
@@ -118,6 +117,9 @@
+
+ onColumnApply('io', p)" />
+
@@ -128,19 +130,31 @@
+
+ onColumnApply('io', p)" />
+
- {{ $t('product.product-modbus.562372-32') }}
- {{ $t('product.product-modbus.562372-33') }}
+ {{ $t('product.product-modbus.562372-32') }}
+ {{ $t('product.product-modbus.562372-33') }}
+
+ onColumnApply('io', p)" />
+
+
+ {{ $t('product.product-modbus.562372-34') }}
+
+ {{ $t('product.product-modbus.562372-326') }}
+
+
{{ $t('product.product-modbus.562372-35') }}
@@ -180,14 +194,15 @@
{{ $t('product.product-modbus.562372-40') }}
-
+
{{ $t('product.product-modbus.562372-41') }}
-
+
+
@@ -197,14 +212,12 @@
v-model="scope.row.identifier"
:placeholder="$t('product.product-modbus.562372-26')"
size="mini"
- :ref="`selectData${scope.$index}`"
- @change="(e) => updateSelectThingsModel({ newVal: e, oldVal: $refs[`selectData${scope.$index}`].value, justiceSelect: 'isSelectData' })"
>
{{ $t('product.product-modbus.562372-27') }}
{{ $t('product.product-modbus.562372-28') }}
-
+
{{ item.modelName }}
{{ item.identifier }}
@@ -212,26 +225,37 @@
-
+
+
+ onColumnApply('data', p)" />
+
+
+ onColumnApply('data', p)" />
+
+
+ onColumnApply('data', p)" />
+
- {{ $t('product.product-modbus.562372-32') }}
- {{ $t('product.product-modbus.562372-33') }}
+ {{ $t('product.product-modbus.562372-32') }}
+ {{ $t('product.product-modbus.562372-33') }}
-
+
+ onColumnApply('data', p)" />
+
@@ -239,11 +263,20 @@
+
+ onColumnApply('data', p)" />
+
+
+ {{ $t('product.product-modbus.562372-34') }}
+
+ {{ $t('product.product-modbus.562372-326') }}
+
+
{{ $t('product.product-modbus.562372-35') }}
@@ -264,7 +297,7 @@
-
+
@@ -275,12 +308,14 @@ import { listConfig, addBatch } from '@/api/iot/modbusConfig';
import { getlListModbus } from '@/api/iot/model';
import thingsList from './things-model-list';
import importBatch from './components/batchImportModbus';
+import modbusColumnBatch from './components/modbusColumnBatch';
+import { deriveQuantity, resolveQuantity } from './modbus-derive';
import Sortable from 'sortablejs';
import { addOrUpdate, getByProductId } from '@/api/iot/params';
import { listProductJob } from '@/api/iot/modbusJob';
export default {
- name: 'product-modbus-copy',
+ name: 'product-modbus',
dicts: ['iot_modbus_data_type', 'iot_yes_no', 'data_collect_type', 'device_status_deter', 'iot_modbus_poll_time'],
props: {
product: {
@@ -291,16 +326,16 @@ export default {
components: {
thingsList,
importBatch,
+ modbusColumnBatch,
},
watch: {
// 获取到父组件传递的productId后,刷新列表
product: function (newVal, oldVal) {
- this.productInfo = newVal;
- if (this.productInfo && this.productInfo.productId != 0) {
- this.thingsModelParams.productId = this.productInfo.productId;
- this.queryParamsIO.productId = this.productInfo.productId;
- this.queryParamsData.productId = this.productInfo.productId;
- this.productId = this.productInfo.productId;
+ if (newVal && newVal.productId != 0) {
+ this.thingsModelParams.productId = newVal.productId;
+ this.queryParamsIO.productId = newVal.productId;
+ this.queryParamsData.productId = newVal.productId;
+ this.productId = newVal.productId;
this.getIOList();
this.getDataList();
this.getThingsModelList();
@@ -311,25 +346,26 @@ export default {
enableEditIO: function (n, old) {
//设置行是否拖拽
if (this.sortableIo) this.sortableIo.option('disabled', !n);
- //关闭编辑刷新页面
+ //关闭编辑刷新页面(保存与取消都会走到这里:重拉数据与物模型可选标记,并清空本次编辑的临时状态)
if (!n) {
this.getIOList();
this.getThingsModelList();
+ this.ioSelection = [];
+ this.releasedIo = [];
+ this.delIoIds = [];
}
- //清空删除数组
- this.delIoIds = [];
- this.getThingsModelList();
},
enableEditData: function (n, old) {
//设置行是否拖拽
if (this.sortableData) this.sortableData.option('disabled', !n);
- //关闭编辑刷新页面
+ //关闭编辑刷新页面(保存与取消都会走到这里:重拉数据与物模型可选标记,并清空本次编辑的临时状态)
if (!n) {
this.getDataList();
this.getThingsModelList();
+ this.dataSelection = [];
+ this.releasedData = [];
+ this.delDataIds = [];
}
- //清空删除数组
- this.delDataIds = [];
},
},
data() {
@@ -337,14 +373,6 @@ export default {
// 遮罩层
loadingIO: true,
loadingData: true,
- // 选中数组
- ids: [],
- // 非单个禁用
- single: true,
- // 非多个禁用
- multiple: true,
- // 显示搜索条件
- showSearch: true,
// 总条数
total: 0,
// modbus配置表格数据
@@ -353,10 +381,6 @@ export default {
enableEditIO: false,
//子设备配置是否可编辑
enableSetSlave: false,
- // 弹出层标题
- title: '',
- // 是否显示弹出层
- open: false,
// 查询参数
queryParamsIO: {
pageNum: 1,
@@ -392,10 +416,16 @@ export default {
// 数据寄存器遮罩层
dataLoading: true,
+ //IO表勾选行(列头批量作用域:有勾选=勾选行,否则本页全部行)
+ ioSelection: [],
+ //数据表勾选行
+ dataSelection: [],
+ //本次编辑中被删除行占用的物模型标识符(后端标记要保存后才变,期间放行其重新可选)
+ releasedIo: [],
+ releasedData: [],
+
//可选属性
thingsModelList: [],
- //物模型遮罩层
- thingsLoading: true,
//thingsModel
thingsModelParams: {
pageNum: 1,
@@ -403,7 +433,6 @@ export default {
productId: 0,
isCalculated: 0,
},
- thingsTotal: 0,
// 表单参数
form: {
statusDeter: 1,
@@ -411,15 +440,9 @@ export default {
pollType: 0,
deterTimer: '300',
},
- productInfo: {},
// 表单校验
rules: {
slaveId: [{ required: true, message: this.$t('product.product-modbus.562372-46'), trigger: 'blur' }],
- address: [{ required: true, message: this.$t('product.product-modbus.562372-47'), trigger: 'blur' }],
- isReadonly: [{ required: true, message: this.$t('product.product-modbus.562372-48'), trigger: 'blur' }],
- dataType: [{ required: true, message: this.$t('product.product-modbus.562372-49'), trigger: 'change' }],
- quantity: [{ required: true, message: this.$t('product.product-modbus.562372-50'), trigger: 'blur' }],
- type: [{ required: true, message: this.$t('product.product-modbus.562372-51'), trigger: 'change' }],
},
//IO行拖拽对象
sortableIo: null,
@@ -438,6 +461,35 @@ export default {
jobTotal: 0,
};
},
+ computed: {
+ //直接读 prop,不再维护副本(副本只在 watch 触发时同步,一旦脱节:
+ //productInfo 停留为 {} → status==1 判定失败 → 「编辑寄存器」按钮不渲染,而批量入口仍可见,用户只会得到"请先点击编辑寄存器"的无效提示)
+ productInfo() {
+ return this.product || {};
+ },
+ //批量入口禁用原因(提示要指向用户当下真正该做的动作)
+ editDisabledTip() {
+ if (this.productInfo.status != 1) return this.$t('product.product-modbus.562372-338');
+ return this.$t('product.product-modbus.562372-331');
+ },
+ //列头批量作用域:有勾选行用勾选行,否则本页全部行(不再问第二遍)
+ //一律按表格行序返回:el-table 的 selection 是"勾选先后"顺序,地址生成必须按行序
+ ioScopeRows() {
+ const rows = this.ioSelection.length ? this.ioSelection : this.configList;
+ return this.sortByTableOrder(rows, this.configList);
+ },
+ dataScopeRows() {
+ const rows = this.dataSelection.length ? this.dataSelection : this.dataModbusList;
+ return this.sortByTableOrder(rows, this.dataModbusList);
+ },
+ //作用域来源(仅用于面板文案:作用:本页 N 行 / 已勾选 N 行)
+ ioScopeMode() {
+ return this.ioSelection.length ? 'selected' : 'all';
+ },
+ dataScopeMode() {
+ return this.dataSelection.length ? 'selected' : 'all';
+ },
+ },
mounted() {
this.rowDropIo();
this.rowDropData();
@@ -475,35 +527,143 @@ export default {
},
/** 查询产品物模型列表 */
getThingsModelList() {
- this.thingsLoading = true;
getlListModbus(this.thingsModelParams).then((response) => {
this.$refs.thingsListRef.modelList = response.rows;
this.thingsModelList = response.rows;
- this.thingsTotal = response.total;
- this.thingsLoading = false;
});
},
- // 取消按钮
- cancel() {
- this.open = false;
- this.reset();
+ // IO表勾选(仅编辑态可勾选)
+ ioSelectable() {
+ return this.enableEditIO;
},
- // 表单重置
- reset() {
- this.form = {
- statusDeter: 1,
- slaveId: null,
- // pollType: '0',
- deterTime: '300',
- };
- this.resetForm('form');
+ // 数据表勾选(仅编辑态可勾选)
+ dataSelectable() {
+ return this.enableEditData;
+ },
+ handleIoSelectionChange(selection) {
+ this.ioSelection = selection;
+ },
+ handleDataSelectionChange(selection) {
+ this.dataSelection = selection;
+ },
+ // 按表格行序重排(传入的已是整个列表时直接返回,不产生新数组)
+ sortByTableOrder(rows, list) {
+ if (rows === list) return rows;
+ return rows.slice().sort((a, b) => list.indexOf(a) - list.indexOf(b));
},
- // 多选框选中数据
- handleSelectionChange(selection) {
- this.ids = selection.map((item) => item.id);
- this.single = selection.length !== 1;
- this.multiple = !selection.length;
+ /**
+ * 寄存器地址一键生成:从起始地址开始,按各行占用的寄存器个数(读取个数)依次推进。
+ * 步长口径 = modbus-derive#resolveQuantity:dataType 非空以派生值为准,dataType 为空的历史行回退行内 quantity,再缺省 1。
+ * 仅数据寄存器使用(IO 寄存器同地址按 bitOrder 取位,地址不递增)。
+ */
+ applyAddressAuto(table, rows, startAddress) {
+ if (!rows.length) return;
+ const list = table === 'io' ? this.configList : this.dataModbusList;
+ const from = Number(startAddress);
+ let cursor = from;
+ let last = from;
+ rows.forEach((row) => {
+ row.address = cursor;
+ last = cursor;
+ cursor += resolveQuantity(row);
+ });
+ this.$modal.msgSuccess(this.$t('product.product-modbus.562372-336', { n: rows.length, from, to: last }));
+ // 冲突检测:同从机地址 + 同寄存器地址会被 ModbusProtocol 分组解析互相覆盖,本页内先提示
+ const seen = Object.create(null);
+ let duplicate = 0;
+ list.forEach((row) => {
+ const key = `${row.slave}-${row.address}`;
+ if (seen[key]) duplicate++;
+ else seen[key] = true;
+ });
+ if (duplicate > 0) {
+ this.$modal.msgWarning(this.$t('product.product-modbus.562372-337', { m: duplicate }));
+ }
+ },
+
+ /**
+ * 物模型「可否选中」派生判定(全表统一口径,不再增量改写 thingsModelList):
+ * ① 本次编辑刚删除释放的标识符放行(后端标记要保存后才变)
+ * ② 当前表内存中已占用的标识符禁用(覆盖未保存的新增行)
+ * ③ 后端回传的 isSelectIo/isSelectData 兜底
+ */
+ isOptionDisabled(item, justiceSelect) {
+ const released = justiceSelect === 'isSelectIo' ? this.releasedIo : this.releasedData;
+ if (released.indexOf(item.identifier) !== -1) return false;
+ const list = justiceSelect === 'isSelectIo' ? this.configList : this.dataModbusList;
+ if (list.some((row) => row.identifier && row.identifier === item.identifier)) return true;
+ return !item[justiceSelect];
+ },
+
+ /**
+ * 列头批量应用:一列一个入口,谁的值谁负责。
+ * - dataType:写入后联动 quantity = deriveQuantity(dataType)(字典每种类型位宽唯一)
+ * - quantity:仅允许 1/2;dataType 非空且派生值不符的行跳过(避免写出 ModbusProtocol 会丢弃的矛盾行)
+ * - addressAuto:一键生成地址序列(起始地址 + 按各行读取个数递增)
+ * - 其余列直接写
+ */
+ onColumnApply(table, { column, value }) {
+ const rows = table === 'io' ? this.ioScopeRows : this.dataScopeRows;
+ if (column === 'addressAuto') {
+ this.applyAddressAuto(table, rows, value);
+ return;
+ }
+ let changed = 0;
+ let skipped = 0;
+ rows.forEach((row) => {
+ if (column === 'dataType') {
+ row.dataType = value;
+ const quantity = deriveQuantity(value);
+ if (quantity !== null) row.quantity = quantity;
+ changed++;
+ } else if (column === 'quantity') {
+ if (row.dataType && deriveQuantity(row.dataType) !== value) {
+ skipped++;
+ return;
+ }
+ row.quantity = value;
+ changed++;
+ } else {
+ row[column] = value;
+ changed++;
+ }
+ });
+ if (changed > 0) {
+ this.$modal.msgSuccess(this.$t('product.product-modbus.562372-323', { n: changed }));
+ }
+ if (skipped > 0) {
+ this.$modal.msgWarning(this.$t('product.product-modbus.562372-325', { m: skipped }));
+ }
+ },
+
+ /** 操作列头批量删除:必须先勾选行(删除高危,不做"本页全部"默认),本地删除、保存驱动提交 */
+ handleBatchDelete(table) {
+ const selection = table === 'io' ? this.ioSelection : this.dataSelection;
+ if (!selection.length) {
+ this.$modal.msgWarning(this.$t('product.product-modbus.562372-327'));
+ return;
+ }
+ const savedCount = selection.filter((row) => row.id).length;
+ this.$confirm(this.$t('product.product-modbus.562372-328', { n: selection.length, m: savedCount }), this.$t('product.product-modbus.562372-313'), {
+ confirmButtonText: this.$t('product.product-modbus.562372-314'),
+ cancelButtonText: this.$t('product.product-modbus.562372-315'),
+ type: 'warning',
+ })
+ .then(() => {
+ const list = table === 'io' ? this.configList : this.dataModbusList;
+ const delIds = table === 'io' ? this.delIoIds : this.delDataIds;
+ const released = table === 'io' ? this.releasedIo : this.releasedData;
+ selection.forEach((row) => {
+ const index = list.indexOf(row);
+ if (index !== -1) list.splice(index, 1);
+ if (row.id) delIds.push(row.id);
+ if (row.identifier) released.push(row.identifier);
+ });
+ if (table === 'io') this.ioSelection = [];
+ else this.dataSelection = [];
+ })
+ .catch(() => {});
},
/** 新增IO寄存器行操作 */
handleAdd() {
@@ -591,6 +751,11 @@ export default {
const dataModbusList = [];
this.dataModbusList.forEach((dataModbus, index) => {
if (dataModbus.identifier) {
+ //读取个数是数据类型的派生量:提交前回算,但仅当 dataType 非空(历史行 data_type 可空,勿改坏)
+ if (dataModbus.dataType) {
+ const quantity = deriveQuantity(dataModbus.dataType);
+ if (quantity !== null) dataModbus.quantity = quantity;
+ }
dataModbus.sort = dataModbusList.length + 1;
dataModbusList.push(dataModbus);
}
@@ -608,15 +773,13 @@ export default {
this.loadingData = false;
});
},
- /** 删除按钮操作 */
+ /** 删除按钮操作:本地删除 + 收集已保存行 id;释放的物模型占用由 isOptionDisabled 派生判定自动生效 */
handleDelete(row, index, list, justiceSelect) {
- const item = list.splice(index, 1)[0];
- //如果是删除数据寄存器,并且有id就存id
- if (justiceSelect == 'isSelectData' && row.id) this.delDataIds.push(row.id);
- //如果是删除io,并且有id就存id
- if (justiceSelect == 'isSelectIo' && row.id) this.delIoIds.push(row.id);
- //处理选项是否可选
- this.updateSelectThingsModel({ justiceSelect, oldVal: item.identifier });
+ list.splice(index, 1);
+ const delIds = justiceSelect === 'isSelectIo' ? this.delIoIds : this.delDataIds;
+ const released = justiceSelect === 'isSelectIo' ? this.releasedIo : this.releasedData;
+ if (row.id) delIds.push(row.id);
+ if (row.identifier) released.push(row.identifier);
},
/*获取选择的物模型*/
@@ -635,7 +798,6 @@ export default {
type: this.justiceSelect == 'isSelectData' ? 2 : 1,
sort: list.length + 1,
});
- this.updateSelectThingsModel({ justiceSelect: this.justiceSelect, newVal: id });
}
});
},
@@ -684,16 +846,6 @@ export default {
//将选择中那列查到新位置
list.splice(newIndex, 0, curRow);
},
- /**
- * 更新物模型是否可选
- */
- updateSelectThingsModel({ oldVal, newVal, justiceSelect }) {
- //直接改前后值
- const oldIndex = oldVal ? this.thingsModelList.findIndex((thingsModel) => thingsModel.identifier == oldVal) : -1;
- const newIndex = newVal ? this.thingsModelList.findIndex((thingsModel) => thingsModel.identifier == newVal) : -1;
- if (oldIndex != -1) this.thingsModelList[oldIndex][justiceSelect] = true;
- if (newIndex != -1) this.thingsModelList[newIndex][justiceSelect] = false;
- },
/**批量导入 */
batchImport(justiceSelect) {
this.justiceSelect = justiceSelect;
@@ -702,7 +854,7 @@ export default {
/**导出 */
exportModbus(justiceSelect) {
const type = justiceSelect == 'isSelectData' ? 2 : 1;
- const name = justiceSelect == 'isSelectData' ? this.$t('product.product-modbus.562372-17') : this.$t('product.product-modbus.562372-38');
+ const name = justiceSelect == 'isSelectData' ? this.$t('product.product-modbus.562372-38') : this.$t('product.product-modbus.562372-17');
this.download('/modbus/config/exportModbus?type=' + type, {}, `${name}_${new Date().getTime()}.xlsx`);
},
/**获取modbus参数配置*/
@@ -760,16 +912,13 @@ export default {
},
/** 查询轮训任务列列表 */
getTaskList(productId) {
- this.loading = true;
const params = {
pageNum: 1,
pageSize: 10,
productId: productId,
};
listProductJob(params).then((response) => {
- this.jobList = response.rows;
this.jobTotal = response.total;
- this.loading = false;
});
},
/**取消*/
diff --git a/yack-web/src/views/iot/product/things-model-list.vue b/yack-web/src/views/iot/product/things-model-list.vue
index b7671130..44722293 100644
--- a/yack-web/src/views/iot/product/things-model-list.vue
+++ b/yack-web/src/views/iot/product/things-model-list.vue
@@ -46,6 +46,11 @@ export default {
justiceSelect: {
type: String,
default: 'isSelectData'
+ },
+ // 父组件的「可否选中」派生判定(表内内存占用 + 刚删除释放 + 后端标记),与本页下拉同口径
+ judgeDisabled: {
+ type: Function,
+ default: null
}
},
data() {
@@ -92,10 +97,7 @@ export default {
this.selectedList.forEach(selected => {
const findIndex = this.modelList.findIndex(model => model.identifier == selected.identifier)
if (findIndex != -1) {
- const model = this.modelList[findIndex]
- model.isSelectData = false
- model.isSelectIo = false
- this.$refs.thingsModelTable.toggleRowSelection(model, true)
+ this.$refs.thingsModelTable.toggleRowSelection(this.modelList[findIndex], true)
}
})
})
@@ -132,8 +134,11 @@ export default {
closeDialog() {
this.open = false;
},
- //是否可选
+ //是否可选:优先走父组件派生判定(内存占用/刚删除释放/后端标记三合一),无判定时退回后端标记
selectable(item) {
+ if (this.judgeDisabled) {
+ return !this.judgeDisabled(item, this.justiceSelect)
+ }
return item[this.justiceSelect]
}
}
diff --git a/yack-web/src/views/login.vue b/yack-web/src/views/login.vue
index 72c51806..6cd3da95 100644
--- a/yack-web/src/views/login.vue
+++ b/yack-web/src/views/login.vue
@@ -180,6 +180,7 @@ import { getCodeImg, checkBindId, getErrorMsg, socialLogin, bindLogin, getSmsLog
import Cookies from 'js-cookie';
import { encrypt, decrypt } from '@/utils/jsencrypt';
import { setToken } from '@/utils/auth';
+import { getUserSetting } from '@/api/system/userSetting';
import langSelect from '@/layout/components/langSelect';
export default {
@@ -312,6 +313,34 @@ export default {
},
methods: {
+ /**
+ * 解析登录后的跳转目标
+ * 优先级:URL 携带 redirect(分享/未授权跳转) > 个人设置 > 默认大屏
+ * 任何分支失败都退到 /bigscreen,不阻断登录
+ *
+ * 所有登录入口(账号密码 / 短信 / 绑定 / 第三方)都必须走这里,
+ * 否则各写一份硬编码 '/bigscreen' 会让「登录后显示页面」只对其中一种登录方式生效。
+ */
+ resolveLoginRedirect() {
+ if (this.redirect) {
+ return Promise.resolve(this.redirect);
+ }
+ return getUserSetting()
+ .then((res) => {
+ const data = res.data || {};
+ return data.loginRedirect || '/bigscreen';
+ })
+ .catch(() => '/bigscreen');
+ },
+ /**
+ * 按登录后落地页跳转
+ * 独立成方法,避免各登录入口重复写 router.push 与 catch
+ */
+ redirectAfterLogin() {
+ this.resolveLoginRedirect().then((path) => {
+ this.$router.push({ path }).catch(() => {});
+ });
+ },
changesmsForm() {
this.acountLogin = !this.acountLogin;
this.phoneLogin = !this.phoneLogin;
@@ -360,11 +389,7 @@ export default {
// 保存token
this.loading = true;
setToken(res.token);
- this.$router
- .push({
- path: '/bigscreen',
- })
- .catch(() => {});
+ this.redirectAfterLogin();
if (this.captchaOnOff) {
this.getCode();
this.loading = false;
@@ -482,11 +507,7 @@ export default {
this.smsLoading = true;
smsLogin(this.smsLoginForm).then((res) => {
setToken(res.data);
- this.$router
- .push({
- path: '/bigscreen',
- })
- .catch(() => {});
+ this.redirectAfterLogin();
});
}
});
@@ -515,11 +536,7 @@ export default {
.then((res) => {
// 保存token
setToken(res.token);
- this.$router
- .push({
- path: '/bigscreen',
- })
- .catch(() => {});
+ this.redirectAfterLogin();
})
.catch(() => {
this.loading = false;
@@ -570,11 +587,8 @@ export default {
this.$store
.dispatch('Login', this.loginForm)
.then(() => {
- this.$router
- .push({
- path: '/bigscreen',
- })
- .catch(() => {});
+ // 跳转优先级:URL 携带的 redirect(如会话过期前所在页) > 个人设置 > 默认大屏
+ this.redirectAfterLogin();
})
.catch(() => {
this.loading = false;
diff --git a/yack-web/src/views/platform/device-mapping/index.vue b/yack-web/src/views/platform/device-mapping/index.vue
index 6f3814d6..50fe6c42 100644
--- a/yack-web/src/views/platform/device-mapping/index.vue
+++ b/yack-web/src/views/platform/device-mapping/index.vue
@@ -9,7 +9,12 @@
-
+
@@ -354,11 +359,13 @@ import { listPlatformFlow, getPlatformFlow } from '@/api/platform/flow'
import { listDevice, listThingsModel, getDevice, listDeviceShort } from '@/api/iot/device'
import { listGateway } from '@/api/iot/gateway'
import { getDicts } from '@/api/system/dict/data'
+import BatchSerialNumberInput from '@/views/components/BatchSerialNumberInput'
const SENSOR_TYPE_DICT = 'platform_sensor_type'
export default {
name: 'PlatformDeviceMapping',
+ components: { BatchSerialNumberInput },
data() {
return {
loading: false, submitLoading: false, showSearch: true,
diff --git a/yack-web/src/views/system/setting/components/SettingRow.vue b/yack-web/src/views/system/setting/components/SettingRow.vue
new file mode 100644
index 00000000..0bbaae57
--- /dev/null
+++ b/yack-web/src/views/system/setting/components/SettingRow.vue
@@ -0,0 +1,75 @@
+
+
+
{{ label }}
+
+
+
+
{{ desc }}
+
+
+
+
+
+
diff --git a/yack-web/src/views/system/setting/components/UserSettingForm.vue b/yack-web/src/views/system/setting/components/UserSettingForm.vue
new file mode 100644
index 00000000..e4125ebf
--- /dev/null
+++ b/yack-web/src/views/system/setting/components/UserSettingForm.vue
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 保存
+ 仅提交改动过的配置项
+
+
+
+
+
+
+
diff --git a/yack-web/src/views/system/setting/index.vue b/yack-web/src/views/system/setting/index.vue
new file mode 100644
index 00000000..773ba697
--- /dev/null
+++ b/yack-web/src/views/system/setting/index.vue
@@ -0,0 +1,60 @@
+
+
+
系统设置
+
+
+
+
+
全局配置
+
+
+
+
+ {{ readImeiIccid ? '已开启' : '已关闭' }}
+
+
+
+
+
+
+
+
+
diff --git a/yack-web/src/views/system/user/profile/index.vue b/yack-web/src/views/system/user/profile/index.vue
index 3288e0cb..30b22bab 100644
--- a/yack-web/src/views/system/user/profile/index.vue
+++ b/yack-web/src/views/system/user/profile/index.vue
@@ -18,6 +18,10 @@
修改密码
+
+
+ 个人设置
+
@@ -39,6 +43,12 @@
+
@@ -49,11 +59,12 @@ import userAvatar from './userAvatar';
import userInfo from './userInfo';
import baseInfo from './baseInfo';
import resetPwd from './resetPwd';
+import userSettingForm from '@/views/system/setting/components/UserSettingForm';
import { getUserProfile } from '@/api/system/user';
export default {
name: 'Profile',
- components: { userAvatar, userInfo, baseInfo, resetPwd },
+ components: { userAvatar, userInfo, baseInfo, resetPwd, userSettingForm },
data() {
return {
tabIndex: 0,