From e7ebf3b9187fc7577f294c7028277c09b82593ef Mon Sep 17 00:00:00 2001 From: zhugongping Date: Mon, 5 Oct 2026 14:17:37 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=EF=BC=9A=E5=8F=AF=E4=BB=A5=E9=85=8D=E7=BD=AE=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../system/SysUserSettingController.java | 104 ++++++++ .../common/constant/CacheConstants.java | 5 + .../common/constant/UserSettingConstants.java | 79 ++++++ .../fastbee/common/core/redis/RedisCache.java | 26 ++ .../executor/PowerOnTriggerTaskExecutor.java | 27 ++- .../system/domain/LoginRedirectOption.java | 50 ++++ .../fastbee/system/domain/SysUserSetting.java | 111 +++++++++ .../system/mapper/SysUserSettingMapper.java | 47 ++++ .../service/ISysUserSettingService.java | 61 +++++ .../impl/SysUserSettingServiceImpl.java | 228 ++++++++++++++++++ .../mapper/system/SysUserSettingMapper.xml | 98 ++++++++ sql/sys_user_setting.sql | 80 ++++++ yack-web/src/api/system/userSetting.js | 43 ++++ .../src/layout/components/TagsView/index.vue | 95 +++++++- yack-web/src/permission.js | 12 +- yack-web/src/router/index.js | 5 +- yack-web/src/store/modules/tagsView.js | 85 ++++--- yack-web/src/views/login.vue | 54 +++-- .../system/setting/components/SettingRow.vue | 75 ++++++ .../setting/components/UserSettingForm.vue | 104 ++++++++ yack-web/src/views/system/setting/index.vue | 60 +++++ .../src/views/system/user/profile/index.vue | 13 +- 22 files changed, 1400 insertions(+), 62 deletions(-) create mode 100644 fastbee-admin/src/main/java/com/fastbee/web/controller/system/SysUserSettingController.java create mode 100644 fastbee-common/src/main/java/com/fastbee/common/constant/UserSettingConstants.java create mode 100644 fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/LoginRedirectOption.java create mode 100644 fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/SysUserSetting.java create mode 100644 fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/mapper/SysUserSettingMapper.java create mode 100644 fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/ISysUserSettingService.java create mode 100644 fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/impl/SysUserSettingServiceImpl.java create mode 100644 fastbee-service/fastbee-system-service/src/main/resources/mapper/system/SysUserSettingMapper.xml create mode 100644 sql/sys_user_setting.sql create mode 100644 yack-web/src/api/system/userSetting.js create mode 100644 yack-web/src/views/system/setting/components/SettingRow.vue create mode 100644 yack-web/src/views/system/setting/components/UserSettingForm.vue create mode 100644 yack-web/src/views/system/setting/index.vue 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..f12b19a7 --- /dev/null +++ b/fastbee-admin/src/main/java/com/fastbee/web/controller/system/SysUserSettingController.java @@ -0,0 +1,104 @@ +package com.fastbee.web.controller.system; + +import com.fastbee.common.annotation.Log; +import com.fastbee.common.constant.UserSettingConstants; +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.common.utils.StringUtils; +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.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 个人系统设置 + * + *

本接口只处理「当前登录用户自己的」设置,无需额外的菜单权限点: + * 能登录的用户天然有权读写自己的偏好。全局开关不在这里, + * 仍走 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())); + } + + @ApiOperation("保存当前用户固定的任务栏标签") + @PutMapping("/pinnedTags") + public AjaxResult savePinnedTags(@RequestBody List paths) { + userSettingService.savePinnedTags(SecurityUtils.getUserId(), paths, SecurityUtils.getUsername()); + return AjaxResult.success(); + } + + @ApiOperation("保存当前用户的设置") + @Log(title = "个人设置", businessType = BusinessType.UPDATE) + @PutMapping() + public AjaxResult save(@RequestBody SysUserSetting setting) { + Long userId = SecurityUtils.getUserId(); + String redirect = setting.getLoginRedirect(); + if (StringUtils.isEmpty(redirect)) { + // 空值不覆盖,交由默认值兜底,避免把用户显式配置冲掉 + redirect = UserSettingConstants.DEFAULT_LOGIN_REDIRECT; + } + if (!isValidLoginRedirect(redirect)) { + return AjaxResult.error("登录后跳转页面不合法,请从系统菜单中选择"); + } + setting.setLoginRedirect(redirect); + // 审计字段由服务端填充,不接受前端传入,避免越权写入他人操作名 + String username = SecurityUtils.getUsername(); + setting.setCreateBy(username); + setting.setUpdateBy(username); + userSettingService.save(userId, setting); + 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..f5b2ee71 --- /dev/null +++ b/fastbee-common/src/main/java/com/fastbee/common/constant/UserSettingConstants.java @@ -0,0 +1,79 @@ +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 = "[]"; + + 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-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..3e73555a --- /dev/null +++ b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/SysUserSetting.java @@ -0,0 +1,111 @@ +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; + + 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); + 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; + } + + @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("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..40f66ea4 --- /dev/null +++ b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/mapper/SysUserSettingMapper.java @@ -0,0 +1,47 @@ +package com.fastbee.system.mapper; + +import com.fastbee.system.domain.LoginRedirectOption; +import com.fastbee.system.domain.SysUserSetting; + +import java.util.List; + +/** + * 用户系统设置 数据层 + * + * @author Sumuxi + */ +public interface SysUserSettingMapper { + + /** + * 按用户ID查询设置 + * + * @param userId 用户ID + * @return 设置,不存在返回 null + */ + SysUserSetting selectByUserId(Long userId); + + /** + * 保存设置(存在则更新,不存在则插入) + * + * @param setting 设置 + * @return 影响行数 + */ + int upsert(SysUserSetting setting); + + /** + * 仅更新固定标签列 + * + *

用户尚无记录时更新 0 行,调用方需自行判断是否改用 insert。 + * + * @param setting 设置,userId 与 pinnedTags 必填 + * @return 影响行数 + */ + int updatePinnedTags(SysUserSetting setting); + + /** + * 查询登录落地页候选项(可作为页签的菜单) + * + * @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..5927aac3 --- /dev/null +++ b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/ISysUserSettingService.java @@ -0,0 +1,61 @@ +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); + + /** + * 保存用户设置(存在则更新,不存在则插入) + * + * @param userId 用户ID + * @param setting 设置内容,userId 字段忽略,以入参 userId 为准 + * @return 影响行数 + */ + int save(Long userId, SysUserSetting setting); + + /** + * 查询登录落地页候选项 + * + * @return 候选项列表 + */ + List listLoginRedirectOptions(); + + /** + * 保存任务栏固定标签 + * + *

只落用户右键固定的 pinned 集合;路由自身声明的 affix 不在其中。 + * + * @param userId 用户ID + * @param paths 固定标签的路由 path 列表,按固定顺序 + * @param operator 操作人用户名,写入 update_by + */ + void savePinnedTags(Long userId, List paths, 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..d4e0eed6 --- /dev/null +++ b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/impl/SysUserSettingServiceImpl.java @@ -0,0 +1,228 @@ +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 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 数组(元素为带引号的字符串)也不含裸换行。 + */ + 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()); + } + + /** + * 解析缓存值,格式非法时返回 null 走查库 + * + * @param cached 缓存字符串 + * @param userId 用户ID + * @return 设置对象,解析失败返回 null + */ + private SysUserSetting parseCached(String cached, Long userId) { + int idx = cached.indexOf(CACHE_SEPARATOR); + if (idx < 0) { + return null; + } + SysUserSetting setting = SysUserSetting.defaultOf(userId); + setting.setLoginRedirect(cached.substring(0, idx)); + setting.setPinnedTags(cached.substring(idx + 1)); + return setting; + } + + @Override + @DS("master") + public int save(Long userId, SysUserSetting setting) { + SysUserSetting target = SysUserSetting.defaultOf(userId); + target.setLoginRedirect(setting.getLoginRedirect()); + target.setRemark(setting.getRemark()); + Date now = new Date(); + target.setUpdateBy(setting.getUpdateBy()); + target.setUpdateTime(now); + // create_by/create_time 仅首次写入,由 ON DUPLICATE KEY UPDATE 的列清单保证不被覆盖 + target.setCreateBy(setting.getUpdateBy()); + target.setCreateTime(now); + + int rows = userSettingMapper.upsert(target); + // 写库成功后立即刷缓存,避免读到旧值。 + // 回写时保留库中已有的 pinned_tags:upsert 的 ON DUPLICATE 子句不更新该列, + // 内存对象却是默认值,若直接回填缓存会把用户的固定标签"洗"成空数组。 + SysUserSetting latest = userSettingMapper.selectByUserId(userId); + writeCache(getCacheKey(userId), latest == null ? target : latest); + return rows; + } + + @Override + @DS("master") + public void savePinnedTags(Long userId, List paths, String operator) { + String json = normalizePinnedTags(paths); + Date now = new Date(); + + // 先判断记录是否存在,而不是靠 update 的影响行数: + // MySQL 在「值未变化」时 affected_rows 同样为 0,与「记录不存在」无法区分, + // 靠返回值判断会在重复提交同一份数据时误走 insert 分支。 + SysUserSetting exists = userSettingMapper.selectByUserId(userId); + SysUserSetting patch = SysUserSetting.defaultOf(userId); + patch.setPinnedTags(json); + patch.setUpdateBy(operator); + patch.setUpdateTime(now); + + if (exists == null) { + // 首次固定标签:用户还没有设置记录,走 upsert 而非裸 insert,避免并发下撞唯一键 1062 + patch.setLoginRedirect(UserSettingConstants.DEFAULT_LOGIN_REDIRECT); + patch.setCreateBy(operator); + patch.setCreateTime(now); + userSettingMapper.upsert(patch); + } else { + userSettingMapper.updatePinnedTags(patch); + } + // 回缓存:整体重新查一次,保证与库内最终状态一致 + SysUserSetting latest = userSettingMapper.selectByUserId(userId); + writeCache(getCacheKey(userId), latest == null ? patch : latest); + } + + @Override + public List getPinnedTags(Long userId) { + return parsePinnedTags(selectByUserId(userId).getPinnedTags()); + } + + /** + * 校验并序列化固定标签列表 + * + *

只接受站内绝对路径,去重且保持前端给定的固定顺序。 + * path 最终会进 router.push,混入外链会变成开放重定向入口。 + * + * @param paths 前端提交的 path 列表 + * @return JSON 数组字符串 + */ + private String normalizePinnedTags(List paths) { + List valid = new ArrayList<>(); + if (paths != null) { + for (String path : paths) { + if (StringUtils.isEmpty(path) || !path.startsWith("/")) { + continue; + } + if (path.startsWith("//") || path.contains("://") || path.contains("..")) { + continue; + } + if (!valid.contains(path)) { + valid.add(path); + } + } + } + return JSON.toJSONString(valid); + } + + /** + * 解析固定标签,脏数据降级为空列表 + * + *

列是 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..6477db12 --- /dev/null +++ b/fastbee-service/fastbee-system-service/src/main/resources/mapper/system/SysUserSettingMapper.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + select setting_id, user_id, login_redirect, pinned_tags, + create_by, create_time, update_by, update_time, remark + from sys_user_setting + + + + + + + insert into sys_user_setting + (user_id, login_redirect, pinned_tags, create_by, create_time, update_by, update_time, remark) + values + (#{userId}, #{loginRedirect}, #{pinnedTags}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}, #{remark}) + on duplicate key update + login_redirect = values(login_redirect), + update_by = values(update_by), + update_time = values(update_time), + remark = values(remark) + + + + + 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..3b6fb968 --- /dev/null +++ b/sql/sys_user_setting.sql @@ -0,0 +1,80 @@ +-- ============================================= +-- 用户系统设置(用户维度)+ 设备侧全局开关入库 +-- 维度判据:读的时候拿不拿得到 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 天然重建,不入此列。 +-- * 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`; +-- ------------------------------------------------------------ +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;空数组表示未固定', + `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..7791f5f4 --- /dev/null +++ b/yack-web/src/api/system/userSetting.js @@ -0,0 +1,43 @@ +import request from '@/utils/request' + +// 获取当前用户的设置 +export function getUserSetting() { + return request({ + url: '/system/userSetting', + method: 'get' + }) +} + +// 保存当前用户的设置 +export function updateUserSetting(data) { + return request({ + url: '/system/userSetting', + method: 'put', + 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 updatePinnedTags(paths) { + return request({ + url: '/system/userSetting/pinnedTags', + method: 'put', + data: paths + }) +} diff --git a/yack-web/src/layout/components/TagsView/index.vue b/yack-web/src/layout/components/TagsView/index.vue index 4b33af48..08cf470b 100644 --- a/yack-web/src/layout/components/TagsView/index.vue +++ b/yack-web/src/layout/components/TagsView/index.vue @@ -88,6 +88,23 @@ const MORE_PANEL_WIDTH = 200 // 鼠标横向位移超过这个像素才算拖拽,否则仍按普通点击处理 const DRAG_THRESHOLD = 4 +/** + * 归一化路由 path:补齐前导斜杠、根路径归一为 '/' + * + * $route.path 形如 '/iot/device',而 filterAsyncRouter 生成的子路由 path + * 形如 'iot/device'(相对父级)。比较路由时必须先归一化,否则匹配不到。 + * + * @param {string} path 原始 path + * @returns {string} 归一化后的 path,无效输入返回空串 + */ +function normalizePath(path) { + if (!path || typeof path !== 'string') { + return '' + } + const trimmed = path.replace(/^\/+/, '') + return '/' + trimmed +} + export default { components: { ScrollPane }, data() { @@ -264,14 +281,76 @@ export default { this.$store.dispatch('tagsView/addVisitedView', tag) } } - // 2. 恢复上次会话「固定到任务栏」的标签(localStorage),保证刷新/重新登录后仍在固定区 - this.$store.state.tagsView.pinnedViews - .filter(tag => tag.name && !affixPaths.includes(tag.path)) - .forEach(tag => { - this.$store.dispatch('tagsView/addView', Object.assign({}, tag, { - meta: Object.assign({}, tag.meta, { affix: true, pinned: true }) - })) - }) + // 2. 从后端恢复「固定到任务栏」的标签,跨浏览器/跨设备生效。 + // 接口异步,必须等返回后再补建固定区,否则 mounted 阶段读到空列表就再也不会重建。 + this.$store.dispatch('tagsView/loadPinnedViews').then(paths => { + paths + // 路由里已不存在该 path(菜单被删/改名)时丢弃,避免产生打不开的死标签 + .map(path => this.findRouteByPath(path)) + .filter(Boolean) + .forEach(({ route, path: fullPath }) => { + // 路由自身声明的 affix 已在上一步加入,不重复添加 + if (!route.name || affixPaths.indexOf(fullPath) !== -1) { + return + } + // 只取重建标签所需的字段,不要把路由定义上的 component/children 也塞进 visitedViews; + // query/params 一律用空对象:固定标签来自右键菜单,天然不带参数, + // 且 RouterVo.query 是菜单的默认参数 JSON 字符串,直接透传会被 vue-router 当成非法 query + this.$store.dispatch('tagsView/addView', { + path: fullPath, + fullPath: fullPath, + name: route.name, + query: {}, + params: {}, + meta: Object.assign({}, route.meta, { affix: true, pinned: true }) + }) + }) + }) + }, + /** + * 按完整路径在当前路由表中递归查找对应路由 + * + * 后端只存了 path,恢复标签时需要 name/query/meta 才能正常渲染与跳转。 + * + * 两个坑: + * 1. 路径形态不一致 —— $route.path 形如 '/iot/device'(带前导斜杠), + * 而 filterAsyncRouter 生成的子路由 path 形如 'device'(相对父级,不带斜杠)。 + * 2. 子路由 path 是**相对父级**的,必须逐层拼接成完整路径才能与库里的绝对路径比较; + * 只比节点自身 path 的话,'device' 永远匹配不上 '/iot/device'。 + * 因此这里按「父级完整路径 + 当前节点 path」逐层累积。 + * + * 返回值刻意带上拼出来的 path:命中的是路由**节点**,它自己的 route.path 是相对父级的 + * ('device'),调用方若拿 route.path 再归一化会得到 '/device' —— 这正是固定标签 + * 恢复后点开 404 的成因。绝对路径只能由本方法给出,不允许调用方二次推导。 + * + * @param {string} fullPath 目标完整路径,如 /iot/device + * @param {Array} routes 待查找的路由列表,默认取全部路由 + * @param {string} basePath 父级完整路径,递归时内部使用 + * @returns {{route: Object, path: string}|null} 命中的路由与其绝对路径,未命中返回 null + */ + findRouteByPath(fullPath, routes, basePath) { + const target = normalizePath(fullPath) + if (!target) { + return null + } + const list = routes || this.routes + const prefix = basePath === undefined ? '' : basePath + for (const route of list) { + // route.path 已是绝对路径(以 / 开头)时直接用,否则视为相对父级拼接 + const current = route.path.charAt(0) === '/' + ? normalizePath(route.path) + : normalizePath(prefix + '/' + route.path) + if (current === target) { + return { route, path: current } + } + if (route.children && route.children.length) { + const hit = this.findRouteByPath(target, route.children, current) + if (hit) { + return hit + } + } + } + return null }, addTags() { const { name } = this.$route diff --git a/yack-web/src/permission.js b/yack-web/src/permission.js index 9df13bc9..a5b4acd1 100644 --- a/yack-web/src/permission.js +++ b/yack-web/src/permission.js @@ -17,7 +17,10 @@ router.beforeEach((to, from, next) => { to.meta.title && store.dispatch('settings/setTitle', to.meta.title); /* has token*/ if (to.path === '/login') { - next({ path: '/bigscreen' }); + // 已登录还访问登录页:回首页。 + // 这里不再硬编码 /bigscreen —— 登录后的落地页由个人设置决定, + // 真正的跳转在 login.vue 的 resolveLoginRedirect 里完成。 + next({ path: '/' }); NProgress.done(); } else { if (store.getters.roles.length === 0) { @@ -54,7 +57,12 @@ router.beforeEach((to, from, next) => { next(); return; } - next(`/login?redirect=${to.fullPath}`); // 否则全部重定向到登录页 + // 只在「用户主动访问的深层页面」上带 redirect 回跳:登录后回原位是这类场景的诉求。 + // 首页与根路径是应用的默认入口(登出后 location.href='/index'、直接打开站点是 '/'), + // 它们不携带任何「用户意图」;一旦也拼进 redirect,login.vue 里 + // 「redirect 优先于个人设置」的判定会把 sys_user_setting.login_redirect 永久顶掉。 + const isDefaultEntry = to.path === '/' || to.path === '/index'; + next(isDefaultEntry ? '/login' : `/login?redirect=${to.fullPath}`); NProgress.done(); } } diff --git a/yack-web/src/router/index.js b/yack-web/src/router/index.js index 5a4ab484..0f417e90 100644 --- a/yack-web/src/router/index.js +++ b/yack-web/src/router/index.js @@ -71,7 +71,10 @@ export const constantRoutes = [ { path: '', component: Layout, - redirect: '/bigscreen', + // 根路径兜底到首页。系统设置里的「登录后显示页面」不能在这里生效—— + // 路由表是静态定义的、生成时拿不到异步配置,硬拼会与 permission.js 形成跳转循环。 + // 实际落地页由 login.vue 的 resolveLoginRedirect 决定,这里只承接「已登录用户访问 /」。 + redirect: '/index', children: [ { path: 'index', diff --git a/yack-web/src/store/modules/tagsView.js b/yack-web/src/store/modules/tagsView.js index 9fcafe66..8a308496 100644 --- a/yack-web/src/store/modules/tagsView.js +++ b/yack-web/src/store/modules/tagsView.js @@ -1,4 +1,11 @@ -const PINNED_STORAGE_KEY = 'pinned-tags' +import { Message } from 'element-ui' +import { getPinnedTags, updatePinnedTags } from '@/api/system/userSetting' + +// 固定标签的持久化说明: +// 原实现把标签写入浏览器 localStorage(key: pinned-tags),只能在本机本浏览器生效, +// 换浏览器或换电脑就丢。现改为落库(sys_user_setting.pinned_tags), +// 固定项按 path 存取,顺序即固定区顺序。 +// 本模块不再直接依赖 localStorage——组件初始化时从后端拉取,由 TagsView 触发。 // 只持久化「重新打开标签」所需的最小字段,避免把 meta 里的临时/函数数据写进 localStorage function toStorableView(view) { @@ -13,21 +20,9 @@ function toStorableView(view) { } } -function loadPinnedViews() { - try { - const list = JSON.parse(localStorage.getItem(PINNED_STORAGE_KEY)) - return Array.isArray(list) ? list : [] - } catch (e) { - return [] - } -} - -function savePinnedViews(list) { - try { - localStorage.setItem(PINNED_STORAGE_KEY, JSON.stringify(list)) - } catch (e) { - // localStorage 不可用(隐私模式/超配额)时降级为仅当前会话有效 - } +// 落库时只提交 path 列表,其余字段可由路由表还原,避免把冗余的 title/icon 存进库 +function toPinnedPaths(list) { + return (list || []).map(v => v.path).filter(p => !!p) } // 固定区 = 排在普通标签之前的 affix 标签;新固定/取消固定的标签插到固定区末尾 @@ -40,8 +35,9 @@ const state = { visitedViews: [], cachedViews: [], iframeViews: [], - // 用户右键「固定标签到任务栏」的标签,刷新后仍然存在 - pinnedViews: loadPinnedViews() + // 用户右键「固定标签到任务栏」的标签,刷新/换设备后仍然存在 + // 初始为空,由 TagsView 在拿到后端数据后通过 SET_PINNED_VIEWS 填充 + pinnedViews: [] } const mutations = { @@ -125,11 +121,9 @@ const mutations = { if (!state.pinnedViews.some(v => v.path === view.path)) { state.pinnedViews.push(toStorableView(target)) } - savePinnedViews(state.pinnedViews) }, UNPIN_VIEW: (state, view) => { state.pinnedViews = state.pinnedViews.filter(v => v.path !== view.path) - savePinnedViews(state.pinnedViews) const index = state.visitedViews.findIndex(v => v.path === view.path) if (index === -1) return const target = state.visitedViews[index] @@ -138,6 +132,10 @@ const mutations = { state.visitedViews.splice(index, 1) moveToAffixBlockEnd(state.visitedViews, target) }, + // 从后端载入固定标签,tagsView 初始化时调用 + SET_PINNED_VIEWS: (state, list) => { + state.pinnedViews = Array.isArray(list) ? list : [] + }, // 拖拽排序:把 visitedViews 里的 fromIndex 挪到 toIndex(toIndex 是「最终落在」的位置) SORT_VISITED_VIEWS: (state, { fromIndex, toIndex }) => { const list = state.visitedViews @@ -204,6 +202,45 @@ const actions = { addVisitedView({ commit }, view) { commit('ADD_VISITED_VIEW', view) }, + /** + * 从后端载入该用户固定的任务栏标签 + * + * 落库后固定项不再依赖 localStorage,换浏览器/换电脑登录同一账号即可恢复。 + * 失败时保持空列表,不阻塞标签栏渲染。 + */ + loadPinnedViews({ commit }) { + return getPinnedTags() + .then(res => { + const paths = Array.isArray(res.data) ? res.data : [] + // 只保留 path,完整 view 由 TagsView 用当前路由表补全 + commit('SET_PINNED_VIEWS', paths.map(p => ({ path: p }))) + return paths + }) + .catch(() => { + commit('SET_PINNED_VIEWS', []) + return [] + }) + }, + /** + * 固定/取消固定后把最新 path 列表落库 + * + * 失败时只提示不打断操作:内存中的标签区已经变了, + * 用户不至于因为一次网络抖动失去反馈。 + */ + persistPinnedViews({ state }) { + return updatePinnedTags(toPinnedPaths(state.pinnedViews)) + .catch(() => { + Message.warning('固定标签保存失败,下次登录可能不生效') + }) + }, + pinView({ commit, dispatch, state }, view) { + commit('PIN_VIEW', view) + return dispatch('persistPinnedViews') + }, + unpinView({ commit, dispatch, state }, view) { + commit('UNPIN_VIEW', view) + return dispatch('persistPinnedViews') + }, addCachedView({ commit }, view) { commit('ADD_CACHED_VIEW', view) }, @@ -282,14 +319,6 @@ const actions = { updateVisitedView({ commit }, view) { commit('UPDATE_VISITED_VIEW', view) }, - // 固定标签到任务栏(标签栏最左侧固定区,刷新后仍存在) - pinView({ commit }, view) { - commit('PIN_VIEW', view) - }, - // 取消固定 - unpinView({ commit }, view) { - commit('UNPIN_VIEW', view) - }, // 拖拽排序 sortVisitedViews({ commit }, payload) { commit('SORT_VISITED_VIEWS', payload) 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/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 @@ + + + + + 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..df8c08cb --- /dev/null +++ b/yack-web/src/views/system/setting/components/UserSettingForm.vue @@ -0,0 +1,104 @@ + + + + + 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 @@ + + + + + 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, From fe8d204d11bcb3da7ba29f3c45eb60e584f45f14 Mon Sep 17 00:00:00 2001 From: zhugongping Date: Mon, 5 Oct 2026 17:20:54 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=EF=BC=9A=E6=94=AF=E6=8C=81=E6=9F=A5=E8=AF=A2=E5=AD=90=E8=AE=BE?= =?UTF-8?q?=E5=A4=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../system/SysUserSettingController.java | 28 +- .../common/constant/UserSettingConstants.java | 16 + .../controller/device/DeviceController.java | 16 + .../java/com/fastbee/iot/domain/Device.java | 17 + .../PlatformDeviceMappingServiceImpl.java | 22 +- .../fastbee/iot/utils/SerialNumberUtils.java | 52 +++ .../resources/mapper/iot/DeviceMapper.xml | 76 ++++- .../fastbee/system/domain/SysUserSetting.java | 25 ++ .../system/mapper/SysUserSettingMapper.java | 39 ++- .../service/ISysUserSettingService.java | 21 +- .../impl/SysUserSettingServiceImpl.java | 140 +++++--- .../mapper/system/SysUserSettingMapper.xml | 53 ++- sql/sys_user_setting.sql | 21 ++ yack-web/src/api/system/userSetting.js | 19 +- .../src/layout/components/TagsView/index.vue | 20 +- yack-web/src/store/modules/tagsView.js | 151 ++++----- .../BatchSerialNumberInput/index.vue | 312 ++++++++++++++++++ yack-web/src/views/dataCenter/history.vue | 15 + yack-web/src/views/iot/device/index.vue | 9 +- .../views/platform/device-mapping/index.vue | 9 +- .../setting/components/UserSettingForm.vue | 24 +- 21 files changed, 909 insertions(+), 176 deletions(-) create mode 100644 fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/utils/SerialNumberUtils.java create mode 100644 yack-web/src/views/components/BatchSerialNumberInput/index.vue 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 index f12b19a7..f2dd1edf 100644 --- 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 @@ -13,14 +13,15 @@ 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.PostMapping; import org.springframework.web.bind.annotation.PutMapping; 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; -import java.util.List; - /** * 个人系统设置 * @@ -57,10 +58,25 @@ public class SysUserSettingController extends BaseController { return AjaxResult.success(userSettingService.getPinnedTags(SecurityUtils.getUserId())); } - @ApiOperation("保存当前用户固定的任务栏标签") - @PutMapping("/pinnedTags") - public AjaxResult savePinnedTags(@RequestBody List paths) { - userSettingService.savePinnedTags(SecurityUtils.getUserId(), paths, SecurityUtils.getUsername()); + /** + * 固定标签按「单条增量」写入,不再提供整份列表的覆盖写 + * + *

    原先的 {@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(); } 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 index f5b2ee71..d7675767 100644 --- a/fastbee-common/src/main/java/com/fastbee/common/constant/UserSettingConstants.java +++ b/fastbee-common/src/main/java/com/fastbee/common/constant/UserSettingConstants.java @@ -53,6 +53,22 @@ public class UserSettingConstants { */ public static final String EMPTY_PINNED_TAGS = "[]"; + /** + * 是否查询网关子设备的默认值 + * + *

    语义:{@code /iot/device/shortList} 按设备编号查询时({@code serialNumber} 非空): + *

      + *
    1. 若命中的是网关子设备编号,则换算为其所属的网关设备({@code ifnull(gw_dev_code, serial_number)});
    2. + *
    3. 放开产品类型为「网关子设备(4)」的过滤 —— 这类设备 {@code gw_dev_code} 为空、实为独立设备, + * 只是产品被标成 4,默认在列表中被隐藏(否则设备管理页会多出一大批),仅在按编号精确定位时可见。
    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; + private UserSettingConstants() { } 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..63821c33 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()); 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/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/SysUserSetting.java b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/SysUserSetting.java index 3e73555a..09843d7b 100644 --- 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 @@ -45,6 +45,21 @@ public class SysUserSetting extends BaseEntity { @ApiModelProperty(value = "任务栏固定标签JSON数组") private String pinnedTags; + /** + * 是否查询网关子设备 + * + *

    开启后,{@code /iot/device/shortList} 按设备编号查询时: + *

      + *
    1. 命中的若是网关子设备编号,返回其所属的网关设备;
    2. + *
    3. 放开产品类型为「网关子设备(4)」的过滤(这类设备 {@code gw_dev_code} 为空、实为独立设备, + * 只是产品被标成 4,默认隐藏,仅在按编号精确定位时才可见)。
    4. + *
    + *

    列表范围本身不变:{@code gw_dev_code is null} 恒生效,不带编号时 {@code device_type != 4} + * 依然过滤,因此列表中永远不会出现「子设备」条目。 + */ + @ApiModelProperty(value = "是否查询网关子设备(按编号查时换算网关并放开子设备类产品过滤)") + private Boolean queryGatewayChild; + public SysUserSetting() { } @@ -59,6 +74,7 @@ public class SysUserSetting extends BaseEntity { setting.setUserId(userId); setting.setLoginRedirect(UserSettingConstants.DEFAULT_LOGIN_REDIRECT); setting.setPinnedTags(UserSettingConstants.EMPTY_PINNED_TAGS); + setting.setQueryGatewayChild(UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD); return setting; } @@ -94,6 +110,14 @@ public class SysUserSetting extends BaseEntity { this.pinnedTags = pinnedTags; } + public Boolean getQueryGatewayChild() { + return queryGatewayChild; + } + + public void setQueryGatewayChild(Boolean queryGatewayChild) { + this.queryGatewayChild = queryGatewayChild; + } + @Override public String toString() { return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) @@ -101,6 +125,7 @@ public class SysUserSetting extends BaseEntity { .append("userId", getUserId()) .append("loginRedirect", getLoginRedirect()) .append("pinnedTags", getPinnedTags()) + .append("queryGatewayChild", getQueryGatewayChild()) .append("createBy", getCreateBy()) .append("createTime", getCreateTime()) .append("updateBy", getUpdateBy()) 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 index 40f66ea4..4bc6ef2b 100644 --- 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 @@ -2,7 +2,9 @@ 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; /** @@ -29,15 +31,48 @@ public interface SysUserSettingMapper { int upsert(SysUserSetting setting); /** - * 仅更新固定标签列 + * 仅更新固定标签列(整列覆盖) * - *

    用户尚无记录时更新 0 行,调用方需自行判断是否改用 insert。 + *

    只在 {@code addPinnedTag / removePinnedTag} 的「锁定读 → 改 → 写」事务内调用, + * 入参是**锁内读出并改好的完整数组**。对外不再提供「全量覆盖写」的入口—— + * 那会让两个标签页各自拿旧快照互相覆盖,是丢固定标签的直接成因。 * * @param setting 设置,userId 与 pinnedTags 必填 * @return 影响行数 */ int updatePinnedTags(SysUserSetting setting); + /** + * 确保用户设置行存在:不存在则插入默认行,已存在则不做任何修改 + * + *

    固定标签的增量写入要在一行上做「锁定读 → 改 → 写」,行必须先存在。 + * 刻意不用 {@link #upsert}:upsert 的 ON DUPLICATE 子句会覆盖 + * login_redirect / query_gateway_child,把用户已保存的设置冲回默认值。 + * + * @param userId 用户ID + * @param loginRedirect 新行使用的默认登录跳转 + * @param queryGatewayChild 新行使用的默认开关值 + * @param operator 操作人用户名 + * @param now 当前时间 + * @return 影响行数(插入 1 / 已存在 0) + */ + int insertDefaultIfAbsent(@Param("userId") Long userId, + @Param("loginRedirect") String loginRedirect, + @Param("queryGatewayChild") Boolean queryGatewayChild, + @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); + /** * 查询登录落地页候选项(可作为页签的菜单) * 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 index 5927aac3..b03426ec 100644 --- 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 @@ -41,15 +41,28 @@ public interface ISysUserSettingService { List listLoginRedirectOptions(); /** - * 保存任务栏固定标签 + * 固定一个任务栏标签 * - *

    只落用户右键固定的 pinned 集合;路由自身声明的 affix 不在其中。 + *

    增量写入:已固定则原样返回(幂等,**不改动它的位置**),未固定则追加到固定区末尾。 + * 并发安全由服务端「锁定读 → 改 → 写」保证——多个标签页/多端同时固定不会互相覆盖, + * 因为前端不再提交自己那份可能过期的完整列表。 * * @param userId 用户ID - * @param paths 固定标签的路由 path 列表,按固定顺序 + * @param path 要固定的路由 path * @param operator 操作人用户名,写入 update_by */ - void savePinnedTags(Long userId, List paths, String operator); + 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); /** * 查询任务栏固定标签 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 index d4e0eed6..35b519cd 100644 --- 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 @@ -13,6 +13,7 @@ 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; @@ -35,7 +36,8 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService { * 缓存值的字段分隔符 * *

    用 \n 而不是常见的逗号/竖线:loginRedirect 是路由 path 不含换行, - * pinnedTags 是 JSON 数组(元素为带引号的字符串)也不含裸换行。 + * pinnedTags 是 JSON 数组(元素为带引号的字符串)也不含裸换行, + * queryGatewayChild 是布尔字面量,三段互不冲突。 */ private static final String CACHE_SEPARATOR = "\n"; @@ -55,7 +57,7 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService { // 缓存的是扁平字符串而非整个 POJO,理由同 SysConfigServiceImpl#selectConfigByKey: // 本项目 Redis 序列化器是 FastJson2 + SupportAutoType,直接缓存继承 BaseEntity // (含 Map params 等字段)的对象,反序列化存在取值不稳与 - // autoType 风险;缓存两个扁平字段可完全规避。 + // autoType 风险;缓存扁平字段可完全规避。 String cached = redisCache.getStrCacheObject(cacheKey); if (StringUtils.isNotEmpty(cached)) { SysUserSetting fromCache = parseCached(cached, userId); @@ -76,24 +78,31 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService { */ private void writeCache(String cacheKey, SysUserSetting setting) { redisCache.setStrCacheObject(cacheKey, - setting.getLoginRedirect() + CACHE_SEPARATOR + setting.getPinnedTags()); + setting.getLoginRedirect() + CACHE_SEPARATOR + + setting.getPinnedTags() + CACHE_SEPARATOR + + setting.getQueryGatewayChild()); } /** * 解析缓存值,格式非法时返回 null 走查库 * + *

    段数必须严格等于 3:旧版本只缓存了 2 段(loginRedirect + pinnedTags), + * 若按缺省值补齐会读到错误的 queryGatewayChild,故旧格式一律判为未命中, + * 由查库回填新格式。缓存无 TTL,但每个用户最多回填一次。 + * * @param cached 缓存字符串 * @param userId 用户ID * @return 设置对象,解析失败返回 null */ private SysUserSetting parseCached(String cached, Long userId) { - int idx = cached.indexOf(CACHE_SEPARATOR); - if (idx < 0) { + String[] parts = cached.split(CACHE_SEPARATOR, -1); + if (parts.length != 3) { return null; } SysUserSetting setting = SysUserSetting.defaultOf(userId); - setting.setLoginRedirect(cached.substring(0, idx)); - setting.setPinnedTags(cached.substring(idx + 1)); + setting.setLoginRedirect(parts[0]); + setting.setPinnedTags(parts[1]); + setting.setQueryGatewayChild(Boolean.parseBoolean(parts[2])); return setting; } @@ -102,6 +111,10 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService { public int save(Long userId, SysUserSetting setting) { SysUserSetting target = SysUserSetting.defaultOf(userId); target.setLoginRedirect(setting.getLoginRedirect()); + // 未传时落到默认值:列约束为 NOT NULL,直接写 null 会在 STRICT 模式下报错 + target.setQueryGatewayChild(setting.getQueryGatewayChild() == null + ? UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD + : setting.getQueryGatewayChild()); target.setRemark(setting.getRemark()); Date now = new Date(); target.setUpdateBy(setting.getUpdateBy()); @@ -111,41 +124,79 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService { target.setCreateTime(now); int rows = userSettingMapper.upsert(target); - // 写库成功后立即刷缓存,避免读到旧值。 - // 回写时保留库中已有的 pinned_tags:upsert 的 ON DUPLICATE 子句不更新该列, - // 内存对象却是默认值,若直接回填缓存会把用户的固定标签"洗"成空数组。 - SysUserSetting latest = userSettingMapper.selectByUserId(userId); - writeCache(getCacheKey(userId), latest == null ? target : latest); + // 写库成功后立即刷缓存。必须重查库再回填:upsert 的 ON DUPLICATE 子句不更新 + // pinned_tags,而内存对象 target 的 pinnedTags 是默认空数组, + // 直接回填会把用户的固定标签"洗"成空。 + refreshCache(userId, target); return rows; } @Override @DS("master") - public void savePinnedTags(Long userId, List paths, String operator) { - String json = normalizePinnedTags(paths); + @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, operator, now); + List paths = parsePinnedTags(userSettingMapper.selectPinnedTagsForUpdate(userId)); + if (paths.contains(path)) { + // 已固定:幂等返回。不重复追加,也不把它挪到末尾,避免重复点击改变固定区顺序 + return; + } + paths.add(path); + writePinnedTags(userId, paths, operator, now); + } - // 先判断记录是否存在,而不是靠 update 的影响行数: - // MySQL 在「值未变化」时 affected_rows 同样为 0,与「记录不存在」无法区分, - // 靠返回值判断会在重复提交同一份数据时误走 insert 分支。 - SysUserSetting exists = userSettingMapper.selectByUserId(userId); + @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); + patch.setPinnedTags(JSON.toJSONString(paths)); patch.setUpdateBy(operator); patch.setUpdateTime(now); + userSettingMapper.updatePinnedTags(patch); + refreshCache(userId, patch); + } - if (exists == null) { - // 首次固定标签:用户还没有设置记录,走 upsert 而非裸 insert,避免并发下撞唯一键 1062 - patch.setLoginRedirect(UserSettingConstants.DEFAULT_LOGIN_REDIRECT); - patch.setCreateBy(operator); - patch.setCreateTime(now); - userSettingMapper.upsert(patch); - } else { - userSettingMapper.updatePinnedTags(patch); - } - // 回缓存:整体重新查一次,保证与库内最终状态一致 + /** + * 重查一次并回填缓存,保证缓存与库内最终状态一致 + * + * @param userId 用户ID + * @param fallback 重查返回 null 时的兜底对象(正常不会发生) + */ + private void refreshCache(Long userId, SysUserSetting fallback) { SysUserSetting latest = userSettingMapper.selectByUserId(userId); - writeCache(getCacheKey(userId), latest == null ? patch : latest); + writeCache(getCacheKey(userId), latest == null ? fallback : latest); } @Override @@ -154,30 +205,19 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService { } /** - * 校验并序列化固定标签列表 + * 校验固定标签 path 是否合法 * - *

    只接受站内绝对路径,去重且保持前端给定的固定顺序。 - * path 最终会进 router.push,混入外链会变成开放重定向入口。 + *

    只接受站内绝对路径,且排除协议相对地址({@code //host})、 + * 带协议头的地址与路径穿越。path 最终会进 router.push,混入外链会变成开放重定向入口。 * - * @param paths 前端提交的 path 列表 - * @return JSON 数组字符串 + * @param path 待校验的路由 path + * @return 合法返回 true */ - private String normalizePinnedTags(List paths) { - List valid = new ArrayList<>(); - if (paths != null) { - for (String path : paths) { - if (StringUtils.isEmpty(path) || !path.startsWith("/")) { - continue; - } - if (path.startsWith("//") || path.contains("://") || path.contains("..")) { - continue; - } - if (!valid.contains(path)) { - valid.add(path); - } - } + private boolean isValidPinnedPath(String path) { + if (StringUtils.isEmpty(path) || !path.startsWith("/")) { + return false; } - return JSON.toJSONString(valid); + return !path.startsWith("//") && !path.contains("://") && !path.contains(".."); } /** 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 index 6477db12..f49d7a83 100644 --- 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 @@ -9,6 +9,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + @@ -22,7 +23,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - select setting_id, user_id, login_redirect, pinned_tags, + select setting_id, user_id, login_redirect, pinned_tags, query_gateway_child, create_by, create_time, update_by, update_time, remark from sys_user_setting @@ -37,24 +38,54 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 用 INSERT ... ON DUPLICATE KEY UPDATE 收敛「先查后插」的并发竞态: 并发首次保存不会因唯一键冲突抛 DuplicateKey,也不需要 SELECT FOR UPDATE。 create_time 保留首次写入时间,重复插入时不覆盖。 + pinned_tags 故意不在此更新:它由 tags-view 的独立写入路径维护, + 并入 upsert 会导致保存其它设置时把用户的固定标签冲成默认值。 --> insert into sys_user_setting - (user_id, login_redirect, pinned_tags, create_by, create_time, update_by, update_time, remark) + (user_id, login_redirect, pinned_tags, query_gateway_child, create_by, create_time, update_by, update_time, remark) values - (#{userId}, #{loginRedirect}, #{pinnedTags}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}, #{remark}) + (#{userId}, #{loginRedirect}, #{pinnedTags}, #{queryGatewayChild}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}, #{remark}) on duplicate key update - login_redirect = values(login_redirect), - update_by = values(update_by), - update_time = values(update_time), - remark = values(remark) + login_redirect = values(login_redirect), + query_gateway_child = values(query_gateway_child), + update_by = values(update_by), + update_time = values(update_time), + remark = values(remark) + + insert into sys_user_setting + (user_id, login_redirect, pinned_tags, query_gateway_child, create_by, create_time, update_by, update_time) + values + (#{userId}, #{loginRedirect}, '[]', #{queryGatewayChild}, #{operator}, #{now}, #{operator}, #{now}) + on duplicate key update + user_id = user_id + + + + + + update sys_user_setting diff --git a/sql/sys_user_setting.sql b/sql/sys_user_setting.sql index 3b6fb968..a8bfca7c 100644 --- a/sql/sys_user_setting.sql +++ b/sql/sys_user_setting.sql @@ -17,6 +17,12 @@ -- 现改为落库,同一账号在任何设备登录都能恢复固定区。 -- * 只存用户右键固定的 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 '[]'。 @@ -26,12 +32,27 @@ -- 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`; +-- +-- 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 设置。 -- ------------------------------------------------------------ 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是)', `create_by` varchar(64) DEFAULT NULL COMMENT '创建者', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_by` varchar(64) DEFAULT NULL COMMENT '更新者', diff --git a/yack-web/src/api/system/userSetting.js b/yack-web/src/api/system/userSetting.js index 7791f5f4..37b705ef 100644 --- a/yack-web/src/api/system/userSetting.js +++ b/yack-web/src/api/system/userSetting.js @@ -33,11 +33,22 @@ export function getPinnedTags() { }) } -// 保存当前用户固定的任务栏标签 -export function updatePinnedTags(paths) { +// 固定一个任务栏标签(服务端幂等追加到固定区末尾) +// 刻意不做「整份列表覆盖写」:前端持有的列表可能已被别的标签页改过, +// 整份提交会把对方的改动静默抹掉 +export function addPinnedTag(path) { return request({ url: '/system/userSetting/pinnedTags', - method: 'put', - data: paths + method: 'post', + params: { path } + }) +} + +// 取消固定一个任务栏标签(服务端幂等移除) +export function removePinnedTag(path) { + return request({ + url: '/system/userSetting/pinnedTags', + method: 'delete', + params: { path } }) } diff --git a/yack-web/src/layout/components/TagsView/index.vue b/yack-web/src/layout/components/TagsView/index.vue index 08cf470b..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/history.vue b/yack-web/src/views/dataCenter/history.vue index e107f4e7..5a019593 100644 --- a/yack-web/src/views/dataCenter/history.vue +++ b/yack-web/src/views/dataCenter/history.vue @@ -31,6 +31,14 @@ + + + {{ $t('device.index.105953-18') }} @@ -287,6 +295,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 @@ - + + + @@ -128,19 +130,31 @@ + + +