系统设置:设备管理查下级机构

This commit is contained in:
zhugongping 2026-10-05 18:26:40 +08:00
parent fe8d204d11
commit 7fc8b14781
11 changed files with 245 additions and 111 deletions

View File

@ -1,12 +1,10 @@
package com.fastbee.web.controller.system; package com.fastbee.web.controller.system;
import com.fastbee.common.annotation.Log; import com.fastbee.common.annotation.Log;
import com.fastbee.common.constant.UserSettingConstants;
import com.fastbee.common.core.controller.BaseController; import com.fastbee.common.core.controller.BaseController;
import com.fastbee.common.core.domain.AjaxResult; import com.fastbee.common.core.domain.AjaxResult;
import com.fastbee.common.enums.BusinessType; import com.fastbee.common.enums.BusinessType;
import com.fastbee.common.utils.SecurityUtils; import com.fastbee.common.utils.SecurityUtils;
import com.fastbee.common.utils.StringUtils;
import com.fastbee.system.domain.LoginRedirectOption; import com.fastbee.system.domain.LoginRedirectOption;
import com.fastbee.system.domain.SysUserSetting; import com.fastbee.system.domain.SysUserSetting;
import com.fastbee.system.service.ISysUserSettingService; import com.fastbee.system.service.ISysUserSettingService;
@ -15,8 +13,8 @@ import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; 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.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
@ -80,25 +78,30 @@ public class SysUserSettingController extends BaseController {
return AjaxResult.success(); return AjaxResult.success();
} }
@ApiOperation("保存当前用户的设置") /**
* 部分更新:只提交、也只写「用户改动过的那几项」
*
* <p>刻意不是整体覆盖写(PUT):设置页有多个互相独立的配置项,
* 整体覆盖会让「保存 A 配置」把 B 配置冲回默认值。
* 这里按入参中非 null 的字段逐列更新,未传的字段在 SQL 层面就碰不到。
* <p>用 PATCH 而非 PUT:PUT 的语义是整体替换,与部分更新名实不符。
*/
@ApiOperation("部分更新当前用户的设置")
@Log(title = "个人设置", businessType = BusinessType.UPDATE) @Log(title = "个人设置", businessType = BusinessType.UPDATE)
@PutMapping() @PatchMapping()
public AjaxResult save(@RequestBody SysUserSetting setting) { public AjaxResult update(@RequestBody SysUserSetting setting) {
Long userId = SecurityUtils.getUserId(); if (setting.getLoginRedirect() == null
String redirect = setting.getLoginRedirect(); && setting.getQueryGatewayChild() == null
if (StringUtils.isEmpty(redirect)) { && setting.getShowChild() == null) {
// 空值不覆盖,交由默认值兜底,避免把用户显式配置冲掉 return AjaxResult.error("没有需要保存的配置项");
redirect = UserSettingConstants.DEFAULT_LOGIN_REDIRECT;
} }
if (!isValidLoginRedirect(redirect)) { // 只有在本次确实要改「登录后显示页面」时才做白名单校验;
// 未传(null)表示本次不改它,不能顺手用默认值把它覆盖掉
if (setting.getLoginRedirect() != null && !isValidLoginRedirect(setting.getLoginRedirect())) {
return AjaxResult.error("登录后跳转页面不合法,请从系统菜单中选择"); return AjaxResult.error("登录后跳转页面不合法,请从系统菜单中选择");
} }
setting.setLoginRedirect(redirect);
// 审计字段由服务端填充,不接受前端传入,避免越权写入他人操作名 // 审计字段由服务端填充,不接受前端传入,避免越权写入他人操作名
String username = SecurityUtils.getUsername(); userSettingService.updatePartial(SecurityUtils.getUserId(), setting, SecurityUtils.getUsername());
setting.setCreateBy(username);
setting.setUpdateBy(username);
userSettingService.save(userId, setting);
return AjaxResult.success(); return AjaxResult.success();
} }

View File

@ -69,6 +69,17 @@ public class UserSettingConstants {
*/ */
public static final boolean DEFAULT_QUERY_GATEWAY_CHILD = false; public static final boolean DEFAULT_QUERY_GATEWAY_CHILD = false;
/**
* 设备管理列表是否包含下级机构数据的默认值
*
* <p>语义对应 {@code Device.showChild}:仅在机构用户({@code deptId} 非空)时参与 SQL,
* 开启时 {@code tenant_id} 落在「本机构及全部下级机构用户」集合内,关闭时仅本机构。
* 原实现是设备管理页的勾选框 + 浏览器 localStorage(key {@code device-showChild}),
* 换浏览器/换电脑就丢,现改为落库、由「系统管理 → 系统设置」页维护。
* <p>默认 false = 与引入本开关前完全一致(默认列表不含下级机构数据)。
*/
public static final boolean DEFAULT_SHOW_CHILD = false;
private UserSettingConstants() { private UserSettingConstants() {
} }

View File

@ -60,6 +60,17 @@ public class SysUserSetting extends BaseEntity {
@ApiModelProperty(value = "是否查询网关子设备(按编号查时换算网关并放开子设备类产品过滤)") @ApiModelProperty(value = "是否查询网关子设备(按编号查时换算网关并放开子设备类产品过滤)")
private Boolean queryGatewayChild; private Boolean queryGatewayChild;
/**
* 设备管理列表是否包含下级机构数据
*
* <p>对应 {@code Device.showChild}:仅在机构用户(deptId 非空)时参与 SQL,
* 开启时 {@code tenant_id} 落在「本机构及全部下级机构用户」集合内,关闭时仅本机构。
* 原为设备管理页勾选框 + 浏览器 localStorage,现改为落库、由系统设置页维护;
* 设备管理页只读取该值作为查询参数下发,页面内不再可就地修改。
*/
@ApiModelProperty(value = "设备管理列表是否包含下级机构数据")
private Boolean showChild;
public SysUserSetting() { public SysUserSetting() {
} }
@ -75,6 +86,7 @@ public class SysUserSetting extends BaseEntity {
setting.setLoginRedirect(UserSettingConstants.DEFAULT_LOGIN_REDIRECT); setting.setLoginRedirect(UserSettingConstants.DEFAULT_LOGIN_REDIRECT);
setting.setPinnedTags(UserSettingConstants.EMPTY_PINNED_TAGS); setting.setPinnedTags(UserSettingConstants.EMPTY_PINNED_TAGS);
setting.setQueryGatewayChild(UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD); setting.setQueryGatewayChild(UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD);
setting.setShowChild(UserSettingConstants.DEFAULT_SHOW_CHILD);
return setting; return setting;
} }
@ -118,6 +130,14 @@ public class SysUserSetting extends BaseEntity {
this.queryGatewayChild = queryGatewayChild; this.queryGatewayChild = queryGatewayChild;
} }
public Boolean getShowChild() {
return showChild;
}
public void setShowChild(Boolean showChild) {
this.showChild = showChild;
}
@Override @Override
public String toString() { public String toString() {
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
@ -126,6 +146,7 @@ public class SysUserSetting extends BaseEntity {
.append("loginRedirect", getLoginRedirect()) .append("loginRedirect", getLoginRedirect())
.append("pinnedTags", getPinnedTags()) .append("pinnedTags", getPinnedTags())
.append("queryGatewayChild", getQueryGatewayChild()) .append("queryGatewayChild", getQueryGatewayChild())
.append("showChild", getShowChild())
.append("createBy", getCreateBy()) .append("createBy", getCreateBy())
.append("createTime", getCreateTime()) .append("createTime", getCreateTime())
.append("updateBy", getUpdateBy()) .append("updateBy", getUpdateBy())

View File

@ -23,12 +23,16 @@ public interface SysUserSettingMapper {
SysUserSetting selectByUserId(Long userId); SysUserSetting selectByUserId(Long userId);
/** /**
* 保存设置(存在则更新,不存在则插入) * 部分更新设置:只更新入参中**非 null** 的列,未传的列保持库中原值
* *
* @param setting 设置 * <p>调用方先 {@link #insertDefaultIfAbsent} 保证行存在,否则 UPDATE 0 行会静默丢改动。
* 刻意不提供「整体覆盖写」的入口:设置页每次只提交用户改动过的那几项,
* 整体覆盖会让「保存 A 配置」把 B 配置冲回默认值。
*
* @param setting 设置,userId 必填,其余字段只填本次要改的
* @return 影响行数 * @return 影响行数
*/ */
int upsert(SysUserSetting setting); int updateSetting(SysUserSetting setting);
/** /**
* 仅更新固定标签列(整列覆盖) * 仅更新固定标签列(整列覆盖)
@ -46,12 +50,13 @@ public interface SysUserSettingMapper {
* 确保用户设置行存在:不存在则插入默认行,已存在则不做任何修改 * 确保用户设置行存在:不存在则插入默认行,已存在则不做任何修改
* *
* <p>固定标签的增量写入要在一行上做「锁定读 → 改 → 写」,行必须先存在。 * <p>固定标签的增量写入要在一行上做「锁定读 → 改 → 写」,行必须先存在。
* 刻意不用 {@link #upsert}:upsert 的 ON DUPLICATE 子句会覆盖 * 刻意不用 ON DUPLICATE 写其它列:那会覆盖
* login_redirect / query_gateway_child,把用户已保存的设置冲回默认值。 * login_redirect / query_gateway_child / show_child,把用户已保存的设置冲回默认值。
* *
* @param userId 用户ID * @param userId 用户ID
* @param loginRedirect 新行使用的默认登录跳转 * @param loginRedirect 新行使用的默认登录跳转
* @param queryGatewayChild 新行使用的默认开关值 * @param queryGatewayChild 新行使用的默认开关值
* @param showChild 新行使用的默认「包含下级机构数据」值
* @param operator 操作人用户名 * @param operator 操作人用户名
* @param now 当前时间 * @param now 当前时间
* @return 影响行数(插入 1 / 已存在 0) * @return 影响行数(插入 1 / 已存在 0)
@ -59,6 +64,7 @@ public interface SysUserSettingMapper {
int insertDefaultIfAbsent(@Param("userId") Long userId, int insertDefaultIfAbsent(@Param("userId") Long userId,
@Param("loginRedirect") String loginRedirect, @Param("loginRedirect") String loginRedirect,
@Param("queryGatewayChild") Boolean queryGatewayChild, @Param("queryGatewayChild") Boolean queryGatewayChild,
@Param("showChild") Boolean showChild,
@Param("operator") String operator, @Param("operator") String operator,
@Param("now") Date now); @Param("now") Date now);

View File

@ -25,13 +25,17 @@ public interface ISysUserSettingService {
SysUserSetting selectByUserId(Long userId); SysUserSetting selectByUserId(Long userId);
/** /**
* 保存用户设置(存在则更新,不存在则插入) * 部分更新用户设置:只写 {@code patch} 中非 null 的字段,未传的字段保持库中原值
*
* <p>调用方每次只提交「用户真正改动过」的配置项,因此保存某一项不会把其它项冲成默认值。
* 用户还没有设置行时先落一行默认值再更新,避免 UPDATE 0 行静默丢改动。
* *
* @param userId 用户ID * @param userId 用户ID
* @param setting 设置内容,userId 字段忽略,以入参 userId 为准 * @param patch 只填本次要改的字段,userId 字段忽略,以入参 userId 为准
* @param operator 操作人用户名,写入 update_by
* @return 影响行数 * @return 影响行数
*/ */
int save(Long userId, SysUserSetting setting); int updatePartial(Long userId, SysUserSetting patch, String operator);
/** /**
* 查询登录落地页候选项 * 查询登录落地页候选项

View File

@ -37,7 +37,9 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService {
* *
* <p>用 \n 而不是常见的逗号/竖线:loginRedirect 是路由 path 不含换行, * <p>用 \n 而不是常见的逗号/竖线:loginRedirect 是路由 path 不含换行,
* pinnedTags 是 JSON 数组(元素为带引号的字符串)也不含裸换行, * pinnedTags 是 JSON 数组(元素为带引号的字符串)也不含裸换行,
* queryGatewayChild 是布尔字面量,三段互不冲突。 * showChild 与 queryGatewayChild 是布尔字面量,四段互不冲突。
* <p>字段顺序固定为:loginRedirect | pinnedTags | showChild | queryGatewayChild,
* 与 {@link #parseCached} 一一对应,增删字段必须同步改这两处。
*/ */
private static final String CACHE_SEPARATOR = "\n"; private static final String CACHE_SEPARATOR = "\n";
@ -80,15 +82,17 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService {
redisCache.setStrCacheObject(cacheKey, redisCache.setStrCacheObject(cacheKey,
setting.getLoginRedirect() + CACHE_SEPARATOR setting.getLoginRedirect() + CACHE_SEPARATOR
+ setting.getPinnedTags() + CACHE_SEPARATOR + setting.getPinnedTags() + CACHE_SEPARATOR
+ setting.getShowChild() + CACHE_SEPARATOR
+ setting.getQueryGatewayChild()); + setting.getQueryGatewayChild());
} }
/** /**
* 解析缓存值,格式非法时返回 null 走查库 * 解析缓存值,格式非法时返回 null 走查库
* *
* <p>段数必须严格等于 3:旧版本只缓存了 2 段(loginRedirect + pinnedTags), * <p>段数必须严格等于 4:历史版本只缓存了 2 段(loginRedirect + pinnedTags)、
* 若按缺省值补齐会读到错误的 queryGatewayChild,故旧格式一律判为未命中, * 后又扩到 3 段(追加 queryGatewayChild)。若按缺省值补齐,会把旧格式里
* 由查库回填新格式。缓存无 TTL,但每个用户最多回填一次。 * 那个位置的字段当成 showChild 读出来,故段数不等一律判为未命中,由查库回填新格式。
* 缓存无 TTL,但每个用户最多回填一次。
* *
* @param cached 缓存字符串 * @param cached 缓存字符串
* @param userId 用户ID * @param userId 用户ID
@ -96,38 +100,33 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService {
*/ */
private SysUserSetting parseCached(String cached, Long userId) { private SysUserSetting parseCached(String cached, Long userId) {
String[] parts = cached.split(CACHE_SEPARATOR, -1); String[] parts = cached.split(CACHE_SEPARATOR, -1);
if (parts.length != 3) { if (parts.length != 4) {
return null; return null;
} }
SysUserSetting setting = SysUserSetting.defaultOf(userId); SysUserSetting setting = SysUserSetting.defaultOf(userId);
setting.setLoginRedirect(parts[0]); setting.setLoginRedirect(parts[0]);
setting.setPinnedTags(parts[1]); setting.setPinnedTags(parts[1]);
setting.setQueryGatewayChild(Boolean.parseBoolean(parts[2])); setting.setShowChild(Boolean.parseBoolean(parts[2]));
setting.setQueryGatewayChild(Boolean.parseBoolean(parts[3]));
return setting; return setting;
} }
@Override @Override
@DS("master") @DS("master")
public int save(Long userId, SysUserSetting setting) { @Transactional(rollbackFor = Exception.class)
SysUserSetting target = SysUserSetting.defaultOf(userId); public int updatePartial(Long userId, SysUserSetting patch, String operator) {
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(); Date now = new Date();
target.setUpdateBy(setting.getUpdateBy()); // 行必须先存在,否则后面的部分更新会 UPDATE 0 行、静默丢掉本次改动
target.setUpdateTime(now); userSettingMapper.insertDefaultIfAbsent(userId, UserSettingConstants.DEFAULT_LOGIN_REDIRECT,
// create_by/create_time 仅首次写入,由 ON DUPLICATE KEY UPDATE 的列清单保证不被覆盖 UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD, UserSettingConstants.DEFAULT_SHOW_CHILD,
target.setCreateBy(setting.getUpdateBy()); operator, now);
target.setCreateTime(now); patch.setUserId(userId);
patch.setUpdateBy(operator);
int rows = userSettingMapper.upsert(target); patch.setUpdateTime(now);
// 写库成功后立即刷缓存。必须重查库再回填:upsert 的 ON DUPLICATE 子句不更新 int rows = userSettingMapper.updateSetting(patch);
// pinned_tags,而内存对象 target 的 pinnedTags 是默认空数组, // 写库成功后重查库再回填缓存:patch 里只有本次改动的字段,
// 直接回填会把用户的固定标签"洗"成空。 // 直接拿它刷缓存会把未改动字段写成 null。
refreshCache(userId, target); refreshCache(userId, patch);
return rows; return rows;
} }
@ -141,7 +140,8 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService {
Date now = new Date(); Date now = new Date();
// 行必须先存在,后面的锁定读才有东西可锁 // 行必须先存在,后面的锁定读才有东西可锁
userSettingMapper.insertDefaultIfAbsent(userId, UserSettingConstants.DEFAULT_LOGIN_REDIRECT, userSettingMapper.insertDefaultIfAbsent(userId, UserSettingConstants.DEFAULT_LOGIN_REDIRECT,
UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD, operator, now); UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD, UserSettingConstants.DEFAULT_SHOW_CHILD,
operator, now);
List<String> paths = parsePinnedTags(userSettingMapper.selectPinnedTagsForUpdate(userId)); List<String> paths = parsePinnedTags(userSettingMapper.selectPinnedTagsForUpdate(userId));
if (paths.contains(path)) { if (paths.contains(path)) {
// 已固定:幂等返回。不重复追加,也不把它挪到末尾,避免重复点击改变固定区顺序 // 已固定:幂等返回。不重复追加,也不把它挪到末尾,避免重复点击改变固定区顺序

View File

@ -10,6 +10,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="loginRedirect" column="login_redirect" /> <result property="loginRedirect" column="login_redirect" />
<result property="pinnedTags" column="pinned_tags" /> <result property="pinnedTags" column="pinned_tags" />
<result property="queryGatewayChild" column="query_gateway_child" /> <result property="queryGatewayChild" column="query_gateway_child" />
<result property="showChild" column="show_child" />
<result property="createBy" column="create_by" /> <result property="createBy" column="create_by" />
<result property="createTime" column="create_time" /> <result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" /> <result property="updateBy" column="update_by" />
@ -23,7 +24,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap> </resultMap>
<sql id="selectUserSettingVo"> <sql id="selectUserSettingVo">
select setting_id, user_id, login_redirect, pinned_tags, query_gateway_child, select setting_id, user_id, login_redirect, pinned_tags, query_gateway_child, show_child,
create_by, create_time, update_by, update_time, remark create_by, create_time, update_by, update_time, remark
from sys_user_setting from sys_user_setting
</sql> </sql>
@ -34,37 +35,38 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</select> </select>
<!-- <!--
user_id 上有唯一索引 uk_sys_user_setting_user, 部分更新:只更新**显式传入**(非 null)的列。
用 INSERT ... ON DUPLICATE KEY UPDATE 收敛「先查后插」的并发竞态: 设置页每次只提交用户真正改动过的那几项,因此保存 A 配置不可能把 B 配置冲成默认值——
并发首次保存不会因唯一键冲突抛 DuplicateKey,也不需要 SELECT FOR UPDATE。 这是把写入从「整体覆盖」拆成「单量提交」的关键:
create_time 保留首次写入时间,重复插入时不覆盖。 · Boolean 为 null = 本次不改这一项(显式传 false 与不传是两回事,包装类型能区分)
pinned_tags 故意不在此更新:它由 tags-view 的独立写入路径维护, · String 为 null = 本次不改这一项
并入 upsert 会导致保存其它设置时把用户的固定标签冲成默认值。 刻意不碰 pinned_tags:它由 tags-view 的独立写入路径维护,混进来会互相覆盖。
调用方必须先 insertDefaultIfAbsent 保证行存在,否则 UPDATE 0 行会静默丢改动。
--> -->
<insert id="upsert" parameterType="com.fastbee.system.domain.SysUserSetting"> <update id="updateSetting" parameterType="com.fastbee.system.domain.SysUserSetting">
insert into sys_user_setting update sys_user_setting
(user_id, login_redirect, pinned_tags, query_gateway_child, create_by, create_time, update_by, update_time, remark) <set>
values <if test="loginRedirect != null">login_redirect = #{loginRedirect},</if>
(#{userId}, #{loginRedirect}, #{pinnedTags}, #{queryGatewayChild}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}, #{remark}) <if test="queryGatewayChild != null">query_gateway_child = #{queryGatewayChild},</if>
on duplicate key update <if test="showChild != null">show_child = #{showChild},</if>
login_redirect = values(login_redirect), update_by = #{updateBy},
query_gateway_child = values(query_gateway_child), update_time = #{updateTime}
update_by = values(update_by), </set>
update_time = values(update_time), where user_id = #{userId}
remark = values(remark) </update>
</insert>
<!-- <!--
确保设置行存在:不存在则插入默认行,已存在则**什么都不改**。 确保设置行存在:不存在则插入默认行,已存在则**什么都不改**。
固定标签的增量写入要在一行上做「锁定读 → 改 → 写」,行必须先存在。 两处调用:① 固定标签的增量写入要在一行上做「锁定读 → 改 → 写」,行必须先存在;
刻意不用 upsert:它的 ON DUPLICATE 子句会覆盖 login_redirect / query_gateway_child, ② 设置页的部分更新前先兜一行,否则 UPDATE 0 行会静默丢改动。
刻意不用 ON DUPLICATE 写其它列:那会覆盖 login_redirect / query_gateway_child / show_child,
把用户已保存的设置冲回默认值;这里用 user_id = user_id 作为 no-op 更新。 把用户已保存的设置冲回默认值;这里用 user_id = user_id 作为 no-op 更新。
--> -->
<insert id="insertDefaultIfAbsent"> <insert id="insertDefaultIfAbsent">
insert into sys_user_setting insert into sys_user_setting
(user_id, login_redirect, pinned_tags, query_gateway_child, create_by, create_time, update_by, update_time) (user_id, login_redirect, pinned_tags, query_gateway_child, show_child, create_by, create_time, update_by, update_time)
values values
(#{userId}, #{loginRedirect}, '[]', #{queryGatewayChild}, #{operator}, #{now}, #{operator}, #{now}) (#{userId}, #{loginRedirect}, '[]', #{queryGatewayChild}, #{showChild}, #{operator}, #{now}, #{operator}, #{now})
on duplicate key update on duplicate key update
user_id = user_id user_id = user_id
</insert> </insert>
@ -82,8 +84,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</select> </select>
<!-- <!--
整列覆盖写固定标签。与登录跳转是两条独立写入路径:若合并进 upsert, 整列覆盖写固定标签。与设置页的部分更新是两条独立写入路径:
保存登录跳转时会把固定标签一并覆盖成默认值。 若把 pinned_tags 并进 updateSetting,保存登录跳转等配置时会把固定标签一并覆盖掉。
**只在 addPinnedTag / removePinnedTag 的事务内调用**,且在 SELECT ... FOR UPDATE **只在 addPinnedTag / removePinnedTag 的事务内调用**,且在 SELECT ... FOR UPDATE
之后落下,入参是锁内读出并改好的完整数组;对外不再有全量覆盖写的入口。 之后落下,入参是锁内读出并改好的完整数组;对外不再有全量覆盖写的入口。
--> -->

View File

@ -36,6 +36,10 @@
-- ADD COLUMN `query_gateway_child` tinyint(1) NOT NULL DEFAULT 0 -- ADD COLUMN `query_gateway_child` tinyint(1) NOT NULL DEFAULT 0
-- COMMENT '按编号查询时是否查网关子设备(0否1是)' -- COMMENT '按编号查询时是否查网关子设备(0否1是)'
-- AFTER `pinned_tags`; -- 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 非空): -- query_gateway_child:开启后 /iot/device/shortList **按设备编号查询时**(serialNumber 非空):
-- ① 命中的若是网关子设备编号,返回其所属的网关设备(子设备 gw_dev_code 存的就是网关 serial_number, -- ① 命中的若是网关子设备编号,返回其所属的网关设备(子设备 gw_dev_code 存的就是网关 serial_number,
@ -46,6 +50,16 @@
-- 所以短列表始终只出顶层设备(网关 + 直连),既不出现挂在网关下的真子设备, -- 所以短列表始终只出顶层设备(网关 + 直连),既不出现挂在网关下的真子设备,
-- 默认列表也不会多出 device_type=4 的设备。 -- 默认列表也不会多出 device_type=4 的设备。
-- 默认 0。读该值的入口是 DeviceController#shortList,有 SecurityContext,故为 per-user 设置。 -- 默认 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` ( CREATE TABLE IF NOT EXISTS `sys_user_setting` (
`setting_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '设置主键', `setting_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '设置主键',
@ -53,6 +67,7 @@ CREATE TABLE IF NOT EXISTS `sys_user_setting` (
`login_redirect` varchar(128) NOT NULL DEFAULT '/bigscreen' COMMENT '登录后跳转路由path', `login_redirect` varchar(128) NOT NULL DEFAULT '/bigscreen' COMMENT '登录后跳转路由path',
`pinned_tags` text NOT NULL DEFAULT ('[]') COMMENT '任务栏固定标签JSON数组,按序存 path;空数组表示未固定', `pinned_tags` text NOT NULL DEFAULT ('[]') COMMENT '任务栏固定标签JSON数组,按序存 path;空数组表示未固定',
`query_gateway_child` tinyint(1) NOT NULL DEFAULT 0 COMMENT '按编号查询时是否查网关子设备(0否1是)', `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_by` varchar(64) DEFAULT NULL COMMENT '创建者',
`create_time` datetime DEFAULT NULL COMMENT '创建时间', `create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '更新者', `update_by` varchar(64) DEFAULT NULL COMMENT '更新者',

View File

@ -8,11 +8,13 @@ export function getUserSetting() {
}) })
} }
// 保存当前用户的设置 // 部分更新当前用户的设置:data 里只放**本次改动过**的字段,未改动的一律不放
// 用 PATCH 而非 PUT:服务端只按非空字段逐列更新,未传的字段保持库中原值,
// 这样保存某一项配置不会把其它配置冲回默认值
export function updateUserSetting(data) { export function updateUserSetting(data) {
return request({ return request({
url: '/system/userSetting', url: '/system/userSetting',
method: 'put', method: 'patch',
data: data data: data
}) })
} }

View File

@ -120,13 +120,6 @@
</el-dropdown-menu> </el-dropdown-menu>
</el-dropdown> </el-dropdown>
</el-col> </el-col>
<el-col :span="1.5" style="line-height: 28px">
<el-checkbox v-model="queryParams.showChild" style="margin: 0px 10px" @change="handleQuery">
{{ $t('device.index.105953-18') }}
</el-checkbox>
<el-tooltip :content="$t('device.index.105953-19')" placement="top"><i class="el-icon-question"></i>
</el-tooltip>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"> <right-toolbar :showSearch.sync="showSearch" @queryTable="getList">
<template> <template>
<el-tooltip effect="dark" content="切换卡片/列表" placement="top"> <el-tooltip effect="dark" content="切换卡片/列表" placement="top">
@ -401,6 +394,7 @@ import vueQr from 'vue-qr';
import {listDeviceShort, delDevice} from '@/api/iot/device'; import {listDeviceShort, delDevice} from '@/api/iot/device';
import {listGroup} from '@/api/iot/group'; import {listGroup} from '@/api/iot/group';
import {deptsTreeSelect} from '@/api/system/user'; import {deptsTreeSelect} from '@/api/system/user';
import {getUserSetting} from '@/api/system/userSetting';
import {delSipDeviceBySipId} from '@/api/iot/sipdevice'; import {delSipDeviceBySipId} from '@/api/iot/sipdevice';
import auth from '@/plugins/auth'; import auth from '@/plugins/auth';
import Treeselect from '@riophae/vue-treeselect'; import Treeselect from '@riophae/vue-treeselect';
@ -447,12 +441,16 @@ export default {
myGroupList: [], myGroupList: [],
// 机构树 // 机构树
deptOptions: [], deptOptions: [],
// 「是否包含下级机构数据」是否已从个人设置同步完成(首次同步由 created 负责)
showChildLoaded: false,
// 根路径 // 根路径
baseUrl: process.env.VUE_APP_BASE_API, baseUrl: process.env.VUE_APP_BASE_API,
// 查询参数 // 查询参数
queryParams: { queryParams: {
pageNum: 1, pageNum: 1,
pageSize: 12, pageSize: 12,
// 「是否包含下级机构数据」由个人设置(系统管理 → 系统设置)决定,
// 进入页面时由 syncShowChild() 覆盖,页面内不再就地修改
showChild: false, showChild: false,
entrance: 1, entrance: 1,
deviceName: null, deviceName: null,
@ -526,13 +524,13 @@ export default {
this.queryParams.productId = null; this.queryParams.productId = null;
this.queryParams.groupId = null; this.queryParams.groupId = null;
} }
// 恢复"显示下级机构数据"用户选择
const cachedShowChild = this.$cache.local.get('device-showChild');
if (cachedShowChild === true || cachedShowChild === 'true') {
this.queryParams.showChild = true;
}
this.getDeptTree(); this.getDeptTree();
// 先把「是否包含下级机构数据」从个人设置同步过来再拉列表,
// 避免先用默认值查一次、拿到设置后又重查一次的闪动
this.syncShowChild().then(() => {
this.showChildLoaded = true;
this.connectMqtt(); this.connectMqtt();
});
}, },
activated() { activated() {
// 页码筛选(优先恢复页码,无论是否有其他参数变化) // 页码筛选(优先恢复页码,无论是否有其他参数变化)
@ -580,8 +578,46 @@ export default {
// 浏览器回退时,即使没有 t 参数也要刷新列表 // 浏览器回退时,即使没有 t 参数也要刷新列表
this.getList(); this.getList();
} }
this.resyncShowChild();
}, },
methods: { methods: {
/**
* 同步「设备管理是否包含下级机构数据」
*
* 该偏好由系统设置页维护并落 sys_user_setting,本页只读不写。
* 拉取失败时保持默认 false(不写坏值),并返回是否发生了变化,
* 供 activated 判断是否需要重查列表。
*
* @returns {Promise<boolean>} 值是否发生变化
*/
syncShowChild() {
return getUserSetting()
.then((response) => {
const next = !!(response.data && response.data.showChild);
const changed = this.queryParams.showChild !== next;
this.queryParams.showChild = next;
return changed;
})
.catch(() => false);
},
/**
* 页面被重新激活时同步偏好
*
* 本页被 keep-alive 常驻时切回来不会重跑 created,若不在激活时重新同步,
* 在系统设置页改了开关再切回本页将看不到变化。
* 首次进入由 created 负责(它同步完才拉列表),此处跳过,
* 否则会和 created 抢跑、让列表在 MQTT 连上之前就查一次。
*/
resyncShowChild() {
if (!this.showChildLoaded) {
return;
}
this.syncShowChild().then((changed) => {
if (changed) {
this.getList();
}
});
},
/* 连接Mqtt消息服务器 */ /* 连接Mqtt消息服务器 */
async connectMqtt() { async connectMqtt() {
if (this.$mqttTool.client == null) { if (this.$mqttTool.client == null) {
@ -795,7 +831,6 @@ export default {
/** 搜索按钮操作 */ /** 搜索按钮操作 */
handleQuery() { handleQuery() {
this.queryParams.pageNum = 1; this.queryParams.pageNum = 1;
this.$cache.local.set('device-showChild', this.queryParams.showChild);
this.getList(); this.getList();
}, },
/** 重置按钮操作 */ /** 重置按钮操作 */
@ -804,8 +839,8 @@ export default {
this.queryParams.groupId = null; this.queryParams.groupId = null;
this.queryParams.serialNumber = null; this.queryParams.serialNumber = null;
this.queryParams.deptId = null; this.queryParams.deptId = null;
this.queryParams.showChild = false; // 刻意不重置 showChild:「是否包含下级机构数据」是个人设置里的持久偏好,
this.$cache.local.remove('device-showChild'); // 重置按钮只管本次查询条件,改了偏好下次进页面又会被覆盖回来,反而不一致
this.resetForm('queryForm'); this.resetForm('queryForm');
this.handleQuery(); this.handleQuery();
}, },

View File

@ -21,9 +21,13 @@
<el-switch v-model="form.queryGatewayChild" /> <el-switch v-model="form.queryGatewayChild" />
</setting-row> </setting-row>
<setting-row label="设备管理查下级机构" desc="开启后设备管理列表返回本机构及全部下级机构的设备,关闭时仅返回本机构设备。仅对归属了机构的账号生效;该值即设备管理页原先的「显示下级机构数据」勾选项,页面内不再就地切换。">
<el-switch v-model="form.showChild" />
</setting-row>
<div class="setting-actions"> <div class="setting-actions">
<el-button type="primary" icon="el-icon-check" :loading="saving" @click="submitForm">保存</el-button> <el-button type="primary" icon="el-icon-check" :loading="saving" @click="submitForm">保存</el-button>
<el-button icon="el-icon-refresh-left" @click="handleReset">重置</el-button> <span class="setting-actions__hint">仅提交改动过的配置项</span>
</div> </div>
</div> </div>
</template> </template>
@ -37,10 +41,11 @@ import {
import SettingRow from './SettingRow'; import SettingRow from './SettingRow';
// 默认值与后端 UserSettingConstants 保持一致: // 默认值与后端 UserSettingConstants 保持一致:
// 未指定的账号保持原有的数据大屏落地行为;子设备开关默认关闭 // 未指定的账号保持原有的数据大屏落地行为;子设备开关与「查下级机构数据」默认关闭
const DEFAULT_FORM = { const DEFAULT_FORM = {
loginRedirect: '/bigscreen', loginRedirect: '/bigscreen',
queryGatewayChild: false queryGatewayChild: false,
showChild: false
}; };
export default { export default {
@ -51,7 +56,9 @@ export default {
saving: false, saving: false,
optionsLoading: false, optionsLoading: false,
options: [], options: [],
form: { ...DEFAULT_FORM } form: { ...DEFAULT_FORM },
// 服务端当前值的快照:保存时只提交与它不同的字段,未改动的不进 payload
original: { ...DEFAULT_FORM }
}; };
}, },
created() { created() {
@ -73,31 +80,52 @@ export default {
getUserSetting().then((res) => { getUserSetting().then((res) => {
const data = res.data || {}; const data = res.data || {};
// 后端对未配置过的用户返回默认值,此处仅在值缺失时兜底。 // 后端对未配置过的用户返回默认值,此处仅在值缺失时兜底。
// queryGatewayChild 不能用 ||:布尔 false 会被吞掉,导致开关回显错乱。 // 两个布尔开关不能用 ||:布尔 false 会被吞掉,导致开关回显错乱。
this.form = { const form = {
loginRedirect: data.loginRedirect || DEFAULT_FORM.loginRedirect, loginRedirect: data.loginRedirect || DEFAULT_FORM.loginRedirect,
queryGatewayChild: data.queryGatewayChild === null || data.queryGatewayChild === undefined queryGatewayChild: data.queryGatewayChild === null || data.queryGatewayChild === undefined
? DEFAULT_FORM.queryGatewayChild ? DEFAULT_FORM.queryGatewayChild
: data.queryGatewayChild : data.queryGatewayChild,
showChild: data.showChild === null || data.showChild === undefined
? DEFAULT_FORM.showChild
: data.showChild
}; };
this.form = { ...form };
// 快照必须与回显值同源(含兜底后的值),否则未改动也会被当成脏数据提交
this.original = { ...form };
}); });
}, },
/** 只取本次真正改动过的字段,未改动的不进 payload */
buildPatch() {
const patch = {};
Object.keys(this.form).forEach((key) => {
if (this.form[key] !== this.original[key]) {
patch[key] = this.form[key];
}
});
return patch;
},
submitForm() { submitForm() {
const patch = this.buildPatch();
const keys = Object.keys(patch);
if (keys.length === 0) {
// 不发明知无改动的请求;服务端也会拒绝空 payload
this.$modal.msgWarning('没有需要保存的改动');
return;
}
this.saving = true; this.saving = true;
updateUserSetting({ updateUserSetting(patch)
loginRedirect: this.form.loginRedirect,
queryGatewayChild: this.form.queryGatewayChild
})
.then(() => { .then(() => {
this.$modal.msgSuccess('保存成功,下次登录生效'); // 提交成功后推进快照,保证下一次保存只带新的改动
this.original = { ...this.original, ...patch };
// 「登录后显示页面」下次登录才生效,其余开关下次进入对应页面即生效
this.$modal.msgSuccess(keys.includes('loginRedirect')
? '保存成功,登录后显示页面将于下次登录生效'
: '保存成功');
}) })
.finally(() => { .finally(() => {
this.saving = false; this.saving = false;
}); });
},
handleReset() {
this.form = { ...DEFAULT_FORM };
this.getSetting();
} }
} }
}; };
@ -112,5 +140,12 @@ export default {
// 172px = SettingRow 的标签列 160px + 列间距 12px,见 SettingRow.vue // 172px = SettingRow 的标签列 160px + 列间距 12px,见 SettingRow.vue
.setting-actions { .setting-actions {
padding: 16px 0 0 172px; padding: 16px 0 0 172px;
&__hint {
margin-left: 10px;
vertical-align: middle;
color: #909399;
font-size: 12px;
}
} }
</style> </style>