系统设置:支持查询子设备

This commit is contained in:
zhugongping 2026-10-05 17:20:54 +08:00
parent e7ebf3b918
commit fe8d204d11
21 changed files with 909 additions and 176 deletions

View File

@ -13,14 +13,15 @@ import com.fastbee.system.service.ISysUserSettingService;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; 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.GetMapping; 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.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.RestController; 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())); return AjaxResult.success(userSettingService.getPinnedTags(SecurityUtils.getUserId()));
} }
@ApiOperation("保存当前用户固定的任务栏标签") /**
@PutMapping("/pinnedTags") * 固定标签按「单条增量」写入,不再提供整份列表的覆盖写
public AjaxResult savePinnedTags(@RequestBody List<String> paths) { *
userSettingService.savePinnedTags(SecurityUtils.getUserId(), paths, SecurityUtils.getUsername()); * <p>原先的 {@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(); return AjaxResult.success();
} }

View File

@ -53,6 +53,22 @@ public class UserSettingConstants {
*/ */
public static final String EMPTY_PINNED_TAGS = "[]"; public static final String EMPTY_PINNED_TAGS = "[]";
/**
* 是否查询网关子设备的默认值
*
* <p>语义:{@code /iot/device/shortList} <b>按设备编号查询时</b>({@code serialNumber} 非空):
* <ol>
* <li>若命中的是网关子设备编号,则换算为其所属的网关设备({@code ifnull(gw_dev_code, serial_number)});</li>
* <li>放开产品类型为「网关子设备(4)」的过滤 —— 这类设备 {@code gw_dev_code} 为空、实为独立设备,
* 只是产品被标成 4,默认在列表中被隐藏(否则设备管理页会多出一大批),仅在按编号精确定位时可见。</li>
* </ol>
* <p>列表范围本身不变:{@code gw_dev_code is null} 恒生效,不带编号时 {@code device_type != 4} 依然过滤,
* 因此列表中永远不会出现挂在网关下的真子设备,默认列表也不会多出 device_type=4 的设备。
* 默认 false = 与引入本开关前完全一致。
* <p>维度归属:读该值的入口 {@code DeviceController#shortList} 有 SecurityContext,故为 per-user 设置。
*/
public static final boolean DEFAULT_QUERY_GATEWAY_CHILD = false;
private UserSettingConstants() { private UserSettingConstants() {
} }

View File

@ -23,6 +23,9 @@ import com.fastbee.iot.model.SerialNumberVO;
import com.fastbee.iot.model.dto.ThingsModelDTO; import com.fastbee.iot.model.dto.ThingsModelDTO;
import com.fastbee.iot.model.gateWay.SubDeviceCreateByProductVO; import com.fastbee.iot.model.gateWay.SubDeviceCreateByProductVO;
import com.fastbee.iot.service.IDeviceService; 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 com.github.pagehelper.PageInfo;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
@ -56,6 +59,9 @@ public class DeviceController extends BaseController {
@Autowired @Autowired
private RedisCache redisCache; private RedisCache redisCache;
@Autowired
private ISysUserSettingService userSettingService;
/** /**
* 查询设备列表 * 查询设备列表
*/ */
@ -113,6 +119,16 @@ public class DeviceController extends BaseController {
public TableDataInfo shortList(Device device) { public TableDataInfo shortList(Device device) {
startPage(); startPage();
LoginUser loginUser = getLoginUser(); 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()) { if (null == loginUser.getDeptId()) {
// 终端用户查询设备 // 终端用户查询设备
device.setTenantId(loginUser.getUserId()); device.setTenantId(loginUser.getUserId());

View File

@ -79,6 +79,18 @@ public class Device extends BaseEntity {
@Excel(name = "设备编号") @Excel(name = "设备编号")
private String serialNumber; private String serialNumber;
/**
* 设备编号集合(批量查询)
*
* <p>前端一次填入多个编号时,Controller 用 {@code SerialNumberUtils}
* 把 {@link #serialNumber} 拆到本字段,Mapper 走 IN 查询;
* 只有一个编号时不填充本字段,仍走 {@link #serialNumber} 的等值匹配。
*/
@Transient
@ApiModelProperty("设备编号集合(批量查询用)")
@TableField(exist = false)
private List<String> serialNumbers;
/** /**
* 基准点设备ID(结构安全监测用) * 基准点设备ID(结构安全监测用)
* *
@ -288,6 +300,11 @@ public class Device extends BaseEntity {
@TableField(exist = false) @TableField(exist = false)
private Boolean showChild; private Boolean showChild;
@Transient
@ApiModelProperty("按编号查询时是否查询网关子设备:命中子设备编号返回其所属网关,并放开「网关子设备(4)」产品过滤(为空时由后端按当前用户的个人设置填充)")
@TableField(exist = false)
private Boolean queryGatewayChild;
@Transient @Transient
@ApiModelProperty("查询入口(1=设备管理列表(需要查看更新时间) 2=数据联动、项目管理、设备绑定 3=全部设备)") @ApiModelProperty("查询入口(1=设备管理列表(需要查看更新时间) 2=数据联动、项目管理、设备绑定 3=全部设备)")
@TableField(exist = false) @TableField(exist = false)

View File

@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.fastbee.iot.domain.Device; import com.fastbee.iot.domain.Device;
import com.fastbee.iot.mapper.DeviceMapper; import com.fastbee.iot.mapper.DeviceMapper;
import com.fastbee.iot.platform.domain.*; 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.FlowContext;
import com.fastbee.iot.platform.flow.PlatformFlowExecutor; import com.fastbee.iot.platform.flow.PlatformFlowExecutor;
import com.fastbee.iot.platform.mapper.PlatformDeviceMappingMapper; import com.fastbee.iot.platform.mapper.PlatformDeviceMappingMapper;
@ -51,16 +52,23 @@ public class PlatformDeviceMappingServiceImpl extends ServiceImpl<PlatformDevice
@Override @Override
public List<PlatformDeviceMapping> selectList(PlatformDeviceMapping query) { public List<PlatformDeviceMapping> selectList(PlatformDeviceMapping query) {
String serialNumber = query.getSerialNumber(); // 设备编号支持一次填多个(中英文逗号/换行/空格分隔),逐个做模糊匹配后按 OR 组合。
// 保留原有 LIKE 语义(命中主设备编号或子设备编号),单值场景与改造前完全等价。
List<String> serialNumbers = SerialNumberUtils.split(query.getSerialNumber());
LambdaQueryWrapper<PlatformDeviceMapping> wrapper = Wrappers.<PlatformDeviceMapping>lambdaQuery() LambdaQueryWrapper<PlatformDeviceMapping> wrapper = Wrappers.<PlatformDeviceMapping>lambdaQuery()
.eq(query.getPlatformId() != null, PlatformDeviceMapping::getPlatformId, query.getPlatformId()) .eq(query.getPlatformId() != null, PlatformDeviceMapping::getPlatformId, query.getPlatformId())
.eq(query.getDeviceId() != null, PlatformDeviceMapping::getDeviceId, query.getDeviceId()) .eq(query.getDeviceId() != null, PlatformDeviceMapping::getDeviceId, query.getDeviceId())
.and(StringUtils.hasText(serialNumber), w -> w .and(!serialNumbers.isEmpty(), w -> {
.like(PlatformDeviceMapping::getSerialNumber, serialNumber) for (int i = 0; i < serialNumbers.size(); i++) {
.or() if (i > 0) {
.apply("EXISTS (SELECT 1 FROM platform_device_sub_mapping s " + w.or();
"WHERE s.parent_mapping_id = platform_device_mapping.id AND s.sub_serial_number LIKE {0})", }
"%" + serialNumber + "%")) 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(StringUtils.hasText(query.getSensorType()), PlatformDeviceMapping::getSensorType, query.getSensorType())
.eq(query.getNetStatus() != null, PlatformDeviceMapping::getNetStatus, query.getNetStatus()) .eq(query.getNetStatus() != null, PlatformDeviceMapping::getNetStatus, query.getNetStatus())
.eq(StringUtils.hasText(query.getEnable()), PlatformDeviceMapping::getEnable, query.getEnable()) .eq(StringUtils.hasText(query.getEnable()), PlatformDeviceMapping::getEnable, query.getEnable())

View File

@ -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;
/**
* 设备编号批量输入解析工具
*
* <p>前端允许一次填入多个设备编号,分隔符支持中英文逗号、换行与空格
* (与批量输入弹框的提示文案一致)。此处统一归一化为去重后的编号集合,
* 供 Mapper 以 IN 查询使用;单个编号沿用原有的等值匹配分支,互不影响。
*
* @author Sumuxi
*/
public final class SerialNumberUtils {
/**
* 分隔符:英文逗号 / 中文逗号 / 任意空白(含换行、制表符)
*
* <p>用 + 合并连续分隔符,避免出现空串元素。
*/
private static final Pattern SEPARATOR = Pattern.compile("[,,\\s]+");
private SerialNumberUtils() {
}
/**
* 拆分并归一化设备编号
*
* @param raw 原始输入,可为 null
* @return 去空、去重且保持输入顺序的编号列表;无有效值时返回空列表(非 null)
*/
public static List<String> split(String raw) {
List<String> result = new ArrayList<>();
if (raw == null) {
return result;
}
// 用 LinkedHashSet 去重并保持输入顺序,避免重复编号导致 IN 列表冗余
Set<String> distinct = new LinkedHashSet<>();
for (String item : SEPARATOR.split(raw)) {
String trimmed = item.trim();
if (!trimmed.isEmpty()) {
distinct.add(trimmed);
}
}
result.addAll(distinct);
return result;
}
}

View File

@ -428,8 +428,16 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<where> <where>
and d.gw_dev_code is null and d.gw_dev_code is null
<if test="entrance != null and (entrance == 1 or entrance == 2)"> <if test="entrance != null and (entrance == 1 or entrance == 2)">
<!--
开启「查询网关子设备」且本次带了设备编号时,放过产品类型为「网关子设备(4)」的设备:
它们 gw_dev_code 为空、实为独立设备,但产品类型被标成子设备,默认必须隐藏
(否则设备管理页默认列表会多出大批此类设备),仅在按编号精确定位时可见。
上面的 gw_dev_code is null 不放开,所以挂在网关下的真子设备始终不会出现。
-->
<if test="queryGatewayChild == null or !queryGatewayChild or serialNumbers == null or serialNumbers.size() == 0">
and (p.device_type is null or p.device_type != 4) and (p.device_type is null or p.device_type != 4)
</if> </if>
</if>
<if test="groupId != null and groupId !=0 "> and g.group_id = #{groupId}</if> <if test="groupId != null and groupId !=0 "> and g.group_id = #{groupId}</if>
<if test="deviceName != null and deviceName != ''"> and d.device_name like concat('%', #{deviceName}, '%')</if> <if test="deviceName != null and deviceName != ''"> and d.device_name like concat('%', #{deviceName}, '%')</if>
<if test="productId != null "> and d.product_id = #{productId}</if> <if test="productId != null "> and d.product_id = #{productId}</if>
@ -441,7 +449,33 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</foreach> </foreach>
</if> </if>
<if test="productName != null and productName != ''"> and d.product_name like concat('%', #{productName}, '%')</if> <if test="productName != null and productName != ''"> and d.product_name like concat('%', #{productName}, '%')</if>
<if test="serialNumber != null and serialNumber != ''"> and d.serial_number = #{serialNumber}</if> <!-- 批量编号走 IN(serialNumbers 由 SerialNumberUtils 拆分而来);serialNumbers 为空时才回落到 serialNumber 等值匹配,兼容未走批量拆分的调用方 -->
<if test="serialNumbers != null and serialNumbers.size() > 0">
<choose>
<!--
开启「查询网关子设备」时,命中子设备编号则回退为其所属网关设备
(子设备 gw_dev_code 存的就是网关 serial_number,非子设备取自身)。
结果集仍受上面的 d.gw_dev_code is null 约束,所以列表里永远不会出现子设备。
-->
<when test="queryGatewayChild != null and queryGatewayChild">
and d.serial_number in (
select ifnull(sub.gw_dev_code, sub.serial_number)
from iot_device sub
where sub.serial_number in
<foreach collection="serialNumbers" item="serialNumberItem" open="(" separator="," close=")">
#{serialNumberItem}
</foreach>
)
</when>
<otherwise>
and d.serial_number in
<foreach collection="serialNumbers" item="serialNumberItem" open="(" separator="," close=")">
#{serialNumberItem}
</foreach>
</otherwise>
</choose>
</if>
<if test="(serialNumbers == null or serialNumbers.size() == 0) and serialNumber != null and serialNumber != ''"> and d.serial_number = #{serialNumber}</if>
<if test="gwDevCode != null and gwDevCode != ''"> and d.gw_dev_code = #{gwDevCode}</if> <if test="gwDevCode != null and gwDevCode != ''"> and d.gw_dev_code = #{gwDevCode}</if>
<if test="status != null "> and d.status = #{status}</if> <if test="status != null "> and d.status = #{status}</if>
<if test="isSimulate != null">and d.is_simulate = #{isSimulate}</if> <if test="isSimulate != null">and d.is_simulate = #{isSimulate}</if>
@ -1027,8 +1061,16 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<where> <where>
and d.gw_dev_code is null and d.gw_dev_code is null
<if test="entrance != null and (entrance == 1 or entrance == 2)"> <if test="entrance != null and (entrance == 1 or entrance == 2)">
<!--
开启「查询网关子设备」且本次带了设备编号时,放过产品类型为「网关子设备(4)」的设备:
它们 gw_dev_code 为空、实为独立设备,但产品类型被标成子设备,默认必须隐藏
(否则设备管理页默认列表会多出大批此类设备),仅在按编号精确定位时可见。
上面的 gw_dev_code is null 不放开,所以挂在网关下的真子设备始终不会出现。
-->
<if test="queryGatewayChild == null or !queryGatewayChild or serialNumbers == null or serialNumbers.size() == 0">
and (p.device_type is null or p.device_type != 4) and (p.device_type is null or p.device_type != 4)
</if> </if>
</if>
<if test="groupId != null and groupId !=0 "> and g.group_id = #{groupId}</if> <if test="groupId != null and groupId !=0 "> and g.group_id = #{groupId}</if>
<if test="deviceName != null and deviceName != ''"> and d.device_name like concat('%', #{deviceName}, '%')</if> <if test="deviceName != null and deviceName != ''"> and d.device_name like concat('%', #{deviceName}, '%')</if>
<if test="productId != null "> and d.product_id = #{productId}</if> <if test="productId != null "> and d.product_id = #{productId}</if>
@ -1040,7 +1082,33 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</foreach> </foreach>
</if> </if>
<if test="productName != null and productName != ''"> and d.product_name like concat('%', #{productName}, '%')</if> <if test="productName != null and productName != ''"> and d.product_name like concat('%', #{productName}, '%')</if>
<if test="serialNumber != null and serialNumber != ''"> and d.serial_number = #{serialNumber}</if> <!-- 批量编号走 IN(serialNumbers 由 SerialNumberUtils 拆分而来);serialNumbers 为空时才回落到 serialNumber 等值匹配,兼容未走批量拆分的调用方 -->
<if test="serialNumbers != null and serialNumbers.size() > 0">
<choose>
<!--
开启「查询网关子设备」时,命中子设备编号则回退为其所属网关设备
(子设备 gw_dev_code 存的就是网关 serial_number,非子设备取自身)。
结果集仍受上面的 d.gw_dev_code is null 约束,所以列表里永远不会出现子设备。
-->
<when test="queryGatewayChild != null and queryGatewayChild">
and d.serial_number in (
select ifnull(sub.gw_dev_code, sub.serial_number)
from iot_device sub
where sub.serial_number in
<foreach collection="serialNumbers" item="serialNumberItem" open="(" separator="," close=")">
#{serialNumberItem}
</foreach>
)
</when>
<otherwise>
and d.serial_number in
<foreach collection="serialNumbers" item="serialNumberItem" open="(" separator="," close=")">
#{serialNumberItem}
</foreach>
</otherwise>
</choose>
</if>
<if test="(serialNumbers == null or serialNumbers.size() == 0) and serialNumber != null and serialNumber != ''"> and d.serial_number = #{serialNumber}</if>
<if test="gwDevCode != null and gwDevCode != ''"> and d.gw_dev_code = #{gwDevCode}</if> <if test="gwDevCode != null and gwDevCode != ''"> and d.gw_dev_code = #{gwDevCode}</if>
<if test="status != null "> and d.status = #{status}</if> <if test="status != null "> and d.status = #{status}</if>
<if test="isSimulate != null">and d.is_simulate = #{isSimulate}</if> <if test="isSimulate != null">and d.is_simulate = #{isSimulate}</if>

View File

@ -45,6 +45,21 @@ public class SysUserSetting extends BaseEntity {
@ApiModelProperty(value = "任务栏固定标签JSON数组") @ApiModelProperty(value = "任务栏固定标签JSON数组")
private String pinnedTags; private String pinnedTags;
/**
* 是否查询网关子设备
*
* <p>开启后,{@code /iot/device/shortList} <b>按设备编号查询时</b>:
* <ol>
* <li>命中的若是网关子设备编号,返回其所属的网关设备;</li>
* <li>放开产品类型为「网关子设备(4)」的过滤(这类设备 {@code gw_dev_code} 为空、实为独立设备,
* 只是产品被标成 4,默认隐藏,仅在按编号精确定位时才可见)。</li>
* </ol>
* <p>列表范围本身不变:{@code gw_dev_code is null} 恒生效,不带编号时 {@code device_type != 4}
* 依然过滤,因此列表中永远不会出现「子设备」条目。
*/
@ApiModelProperty(value = "是否查询网关子设备(按编号查时换算网关并放开子设备类产品过滤)")
private Boolean queryGatewayChild;
public SysUserSetting() { public SysUserSetting() {
} }
@ -59,6 +74,7 @@ public class SysUserSetting extends BaseEntity {
setting.setUserId(userId); setting.setUserId(userId);
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);
return setting; return setting;
} }
@ -94,6 +110,14 @@ public class SysUserSetting extends BaseEntity {
this.pinnedTags = pinnedTags; this.pinnedTags = pinnedTags;
} }
public Boolean getQueryGatewayChild() {
return queryGatewayChild;
}
public void setQueryGatewayChild(Boolean queryGatewayChild) {
this.queryGatewayChild = queryGatewayChild;
}
@Override @Override
public String toString() { public String toString() {
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
@ -101,6 +125,7 @@ public class SysUserSetting extends BaseEntity {
.append("userId", getUserId()) .append("userId", getUserId())
.append("loginRedirect", getLoginRedirect()) .append("loginRedirect", getLoginRedirect())
.append("pinnedTags", getPinnedTags()) .append("pinnedTags", getPinnedTags())
.append("queryGatewayChild", getQueryGatewayChild())
.append("createBy", getCreateBy()) .append("createBy", getCreateBy())
.append("createTime", getCreateTime()) .append("createTime", getCreateTime())
.append("updateBy", getUpdateBy()) .append("updateBy", getUpdateBy())

View File

@ -2,7 +2,9 @@ package com.fastbee.system.mapper;
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 org.apache.ibatis.annotations.Param;
import java.util.Date;
import java.util.List; import java.util.List;
/** /**
@ -29,15 +31,48 @@ public interface SysUserSettingMapper {
int upsert(SysUserSetting setting); int upsert(SysUserSetting setting);
/** /**
* 仅更新固定标签列 * 仅更新固定标签列(整列覆盖)
* *
* <p>用户尚无记录时更新 0 行,调用方需自行判断是否改用 insert。 * <p>只在 {@code addPinnedTag / removePinnedTag} 的「锁定读 → 改 → 写」事务内调用,
* 入参是**锁内读出并改好的完整数组**。对外不再提供「全量覆盖写」的入口——
* 那会让两个标签页各自拿旧快照互相覆盖,是丢固定标签的直接成因。
* *
* @param setting 设置,userId 与 pinnedTags 必填 * @param setting 设置,userId 与 pinnedTags 必填
* @return 影响行数 * @return 影响行数
*/ */
int updatePinnedTags(SysUserSetting setting); int updatePinnedTags(SysUserSetting setting);
/**
* 确保用户设置行存在:不存在则插入默认行,已存在则不做任何修改
*
* <p>固定标签的增量写入要在一行上做「锁定读 → 改 → 写」,行必须先存在。
* 刻意不用 {@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)
*
* <p>必须在事务内调用。固定标签的写入是「读-改-写」,没有行锁时两个并发请求
* 会各自读到同一份旧值,后写的把先写的覆盖掉。
*
* @param userId 用户ID
* @return pinned_tags 原始 JSON 字符串;用户无记录时返回 null
*/
String selectPinnedTagsForUpdate(@Param("userId") Long userId);
/** /**
* 查询登录落地页候选项(可作为页签的菜单) * 查询登录落地页候选项(可作为页签的菜单)
* *

View File

@ -41,15 +41,28 @@ public interface ISysUserSettingService {
List<LoginRedirectOption> listLoginRedirectOptions(); List<LoginRedirectOption> listLoginRedirectOptions();
/** /**
* 保存任务栏固定标签 * 固定一个任务栏标签
* *
* <p>只落用户右键固定的 pinned 集合;路由自身声明的 affix 不在其中。 * <p>增量写入:已固定则原样返回(幂等,**不改动它的位置**),未固定则追加到固定区末尾。
* 并发安全由服务端「锁定读 → 改 → 写」保证——多个标签页/多端同时固定不会互相覆盖,
* 因为前端不再提交自己那份可能过期的完整列表。
* *
* @param userId 用户ID * @param userId 用户ID
* @param paths 固定标签的路由 path 列表,按固定顺序 * @param path 要固定的路由 path
* @param operator 操作人用户名,写入 update_by * @param operator 操作人用户名,写入 update_by
*/ */
void savePinnedTags(Long userId, List<String> paths, String operator); void addPinnedTag(Long userId, String path, String operator);
/**
* 取消固定一个任务栏标签
*
* <p>增量写入:未固定则不做任何修改(幂等)。
*
* @param userId 用户ID
* @param path 要取消固定的路由 path
* @param operator 操作人用户名,写入 update_by
*/
void removePinnedTag(Long userId, String path, String operator);
/** /**
* 查询任务栏固定标签 * 查询任务栏固定标签

View File

@ -13,6 +13,7 @@ import com.fastbee.system.service.ISysUserSettingService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Date; import java.util.Date;
@ -35,7 +36,8 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService {
* 缓存值的字段分隔符 * 缓存值的字段分隔符
* *
* <p>用 \n 而不是常见的逗号/竖线:loginRedirect 是路由 path 不含换行, * <p>用 \n 而不是常见的逗号/竖线:loginRedirect 是路由 path 不含换行,
* pinnedTags 是 JSON 数组(元素为带引号的字符串)也不含裸换行。 * pinnedTags 是 JSON 数组(元素为带引号的字符串)也不含裸换行,
* queryGatewayChild 是布尔字面量,三段互不冲突。
*/ */
private static final String CACHE_SEPARATOR = "\n"; private static final String CACHE_SEPARATOR = "\n";
@ -55,7 +57,7 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService {
// 缓存的是扁平字符串而非整个 POJO,理由同 SysConfigServiceImpl#selectConfigByKey: // 缓存的是扁平字符串而非整个 POJO,理由同 SysConfigServiceImpl#selectConfigByKey:
// 本项目 Redis 序列化器是 FastJson2 + SupportAutoType,直接缓存继承 BaseEntity // 本项目 Redis 序列化器是 FastJson2 + SupportAutoType,直接缓存继承 BaseEntity
// (含 Map<String,Object> params 等字段)的对象,反序列化存在取值不稳与 // (含 Map<String,Object> params 等字段)的对象,反序列化存在取值不稳与
// autoType 风险;缓存两个扁平字段可完全规避。 // autoType 风险;缓存扁平字段可完全规避。
String cached = redisCache.getStrCacheObject(cacheKey); String cached = redisCache.getStrCacheObject(cacheKey);
if (StringUtils.isNotEmpty(cached)) { if (StringUtils.isNotEmpty(cached)) {
SysUserSetting fromCache = parseCached(cached, userId); SysUserSetting fromCache = parseCached(cached, userId);
@ -76,24 +78,31 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService {
*/ */
private void writeCache(String cacheKey, SysUserSetting setting) { private void writeCache(String cacheKey, SysUserSetting setting) {
redisCache.setStrCacheObject(cacheKey, redisCache.setStrCacheObject(cacheKey,
setting.getLoginRedirect() + CACHE_SEPARATOR + setting.getPinnedTags()); setting.getLoginRedirect() + CACHE_SEPARATOR
+ setting.getPinnedTags() + CACHE_SEPARATOR
+ setting.getQueryGatewayChild());
} }
/** /**
* 解析缓存值,格式非法时返回 null 走查库 * 解析缓存值,格式非法时返回 null 走查库
* *
* <p>段数必须严格等于 3:旧版本只缓存了 2 段(loginRedirect + pinnedTags),
* 若按缺省值补齐会读到错误的 queryGatewayChild,故旧格式一律判为未命中,
* 由查库回填新格式。缓存无 TTL,但每个用户最多回填一次。
*
* @param cached 缓存字符串 * @param cached 缓存字符串
* @param userId 用户ID * @param userId 用户ID
* @return 设置对象,解析失败返回 null * @return 设置对象,解析失败返回 null
*/ */
private SysUserSetting parseCached(String cached, Long userId) { private SysUserSetting parseCached(String cached, Long userId) {
int idx = cached.indexOf(CACHE_SEPARATOR); String[] parts = cached.split(CACHE_SEPARATOR, -1);
if (idx < 0) { if (parts.length != 3) {
return null; return null;
} }
SysUserSetting setting = SysUserSetting.defaultOf(userId); SysUserSetting setting = SysUserSetting.defaultOf(userId);
setting.setLoginRedirect(cached.substring(0, idx)); setting.setLoginRedirect(parts[0]);
setting.setPinnedTags(cached.substring(idx + 1)); setting.setPinnedTags(parts[1]);
setting.setQueryGatewayChild(Boolean.parseBoolean(parts[2]));
return setting; return setting;
} }
@ -102,6 +111,10 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService {
public int save(Long userId, SysUserSetting setting) { public int save(Long userId, SysUserSetting setting) {
SysUserSetting target = SysUserSetting.defaultOf(userId); SysUserSetting target = SysUserSetting.defaultOf(userId);
target.setLoginRedirect(setting.getLoginRedirect()); target.setLoginRedirect(setting.getLoginRedirect());
// 未传时落到默认值:列约束为 NOT NULL,直接写 null 会在 STRICT 模式下报错
target.setQueryGatewayChild(setting.getQueryGatewayChild() == null
? UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD
: setting.getQueryGatewayChild());
target.setRemark(setting.getRemark()); target.setRemark(setting.getRemark());
Date now = new Date(); Date now = new Date();
target.setUpdateBy(setting.getUpdateBy()); target.setUpdateBy(setting.getUpdateBy());
@ -111,41 +124,79 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService {
target.setCreateTime(now); target.setCreateTime(now);
int rows = userSettingMapper.upsert(target); int rows = userSettingMapper.upsert(target);
// 写库成功后立即刷缓存,避免读到旧值。 // 写库成功后立即刷缓存。必须重查库再回填:upsert 的 ON DUPLICATE 子句不更新
// 回写时保留库中已有的 pinned_tags:upsert 的 ON DUPLICATE 子句不更新该列, // pinned_tags,而内存对象 target 的 pinnedTags 是默认空数组,
// 内存对象却是默认值,若直接回填缓存会把用户的固定标签"洗"成空数组。 // 直接回填会把用户的固定标签"洗"成空。
SysUserSetting latest = userSettingMapper.selectByUserId(userId); refreshCache(userId, target);
writeCache(getCacheKey(userId), latest == null ? target : latest);
return rows; return rows;
} }
@Override @Override
@DS("master") @DS("master")
public void savePinnedTags(Long userId, List<String> paths, String operator) { @Transactional(rollbackFor = Exception.class)
String json = normalizePinnedTags(paths); public void addPinnedTag(Long userId, String path, String operator) {
if (!isValidPinnedPath(path)) {
return;
}
Date now = new Date(); Date now = new Date();
// 行必须先存在,后面的锁定读才有东西可锁
userSettingMapper.insertDefaultIfAbsent(userId, UserSettingConstants.DEFAULT_LOGIN_REDIRECT,
UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD, operator, now);
List<String> paths = parsePinnedTags(userSettingMapper.selectPinnedTagsForUpdate(userId));
if (paths.contains(path)) {
// 已固定:幂等返回。不重复追加,也不把它挪到末尾,避免重复点击改变固定区顺序
return;
}
paths.add(path);
writePinnedTags(userId, paths, operator, now);
}
// 先判断记录是否存在,而不是靠 update 的影响行数: @Override
// MySQL 在「值未变化」时 affected_rows 同样为 0,与「记录不存在」无法区分, @DS("master")
// 靠返回值判断会在重复提交同一份数据时误走 insert 分支。 @Transactional(rollbackFor = Exception.class)
SysUserSetting exists = userSettingMapper.selectByUserId(userId); public void removePinnedTag(Long userId, String path, String operator) {
if (!isValidPinnedPath(path)) {
return;
}
// 刻意不先建行:没有记录 = 本来就没固定过任何标签,
// 取消固定不该凭空给用户创建一条设置记录
List<String> paths = parsePinnedTags(userSettingMapper.selectPinnedTagsForUpdate(userId));
if (!paths.remove(path)) {
// 没固定过:幂等返回
return;
}
writePinnedTags(userId, paths, operator, new Date());
}
/**
* 把锁定读出来的数组写回并刷新缓存
*
* <p>必须在 {@code selectPinnedTagsForUpdate} 之后、同一事务内调用:
* 行锁只有握到事务提交才释放,提前提交会让并发请求读到中间态。
*
* @param userId 用户ID
* @param paths 锁内读出并改好的完整列表
* @param operator 操作人用户名
* @param now 当前时间
*/
private void writePinnedTags(Long userId, List<String> paths, String operator, Date now) {
SysUserSetting patch = SysUserSetting.defaultOf(userId); SysUserSetting patch = SysUserSetting.defaultOf(userId);
patch.setPinnedTags(json); patch.setPinnedTags(JSON.toJSONString(paths));
patch.setUpdateBy(operator); patch.setUpdateBy(operator);
patch.setUpdateTime(now); 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); userSettingMapper.updatePinnedTags(patch);
refreshCache(userId, patch);
} }
// 回缓存:整体重新查一次,保证与库内最终状态一致
/**
* 重查一次并回填缓存,保证缓存与库内最终状态一致
*
* @param userId 用户ID
* @param fallback 重查返回 null 时的兜底对象(正常不会发生)
*/
private void refreshCache(Long userId, SysUserSetting fallback) {
SysUserSetting latest = userSettingMapper.selectByUserId(userId); SysUserSetting latest = userSettingMapper.selectByUserId(userId);
writeCache(getCacheKey(userId), latest == null ? patch : latest); writeCache(getCacheKey(userId), latest == null ? fallback : latest);
} }
@Override @Override
@ -154,30 +205,19 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService {
} }
/** /**
* 校验并序列化固定标签列表 * 校验固定标签 path 是否合法
* *
* <p>只接受站内绝对路径,去重且保持前端给定的固定顺序。 * <p>只接受站内绝对路径,且排除协议相对地址({@code //host})、
* path 最终会进 router.push,混入外链会变成开放重定向入口。 * 带协议头的地址与路径穿越。path 最终会进 router.push,混入外链会变成开放重定向入口。
* *
* @param paths 前端提交的 path 列表 * @param path 待校验的路由 path
* @return JSON 数组字符串 * @return 合法返回 true
*/ */
private String normalizePinnedTags(List<String> paths) { private boolean isValidPinnedPath(String path) {
List<String> valid = new ArrayList<>();
if (paths != null) {
for (String path : paths) {
if (StringUtils.isEmpty(path) || !path.startsWith("/")) { if (StringUtils.isEmpty(path) || !path.startsWith("/")) {
continue; return false;
} }
if (path.startsWith("//") || path.contains("://") || path.contains("..")) { return !path.startsWith("//") && !path.contains("://") && !path.contains("..");
continue;
}
if (!valid.contains(path)) {
valid.add(path);
}
}
}
return JSON.toJSONString(valid);
} }
/** /**

View File

@ -9,6 +9,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="userId" column="user_id" /> <result property="userId" column="user_id" />
<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="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" />
@ -22,7 +23,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, select setting_id, user_id, login_redirect, pinned_tags, query_gateway_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>
@ -37,24 +38,54 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
用 INSERT ... ON DUPLICATE KEY UPDATE 收敛「先查后插」的并发竞态: 用 INSERT ... ON DUPLICATE KEY UPDATE 收敛「先查后插」的并发竞态:
并发首次保存不会因唯一键冲突抛 DuplicateKey,也不需要 SELECT FOR UPDATE。 并发首次保存不会因唯一键冲突抛 DuplicateKey,也不需要 SELECT FOR UPDATE。
create_time 保留首次写入时间,重复插入时不覆盖。 create_time 保留首次写入时间,重复插入时不覆盖。
pinned_tags 故意不在此更新:它由 tags-view 的独立写入路径维护,
并入 upsert 会导致保存其它设置时把用户的固定标签冲成默认值。
--> -->
<insert id="upsert" parameterType="com.fastbee.system.domain.SysUserSetting"> <insert id="upsert" parameterType="com.fastbee.system.domain.SysUserSetting">
insert into sys_user_setting 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 values
(#{userId}, #{loginRedirect}, #{pinnedTags}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}, #{remark}) (#{userId}, #{loginRedirect}, #{pinnedTags}, #{queryGatewayChild}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}, #{remark})
on duplicate key update on duplicate key update
login_redirect = values(login_redirect), login_redirect = values(login_redirect),
query_gateway_child = values(query_gateway_child),
update_by = values(update_by), update_by = values(update_by),
update_time = values(update_time), update_time = values(update_time),
remark = values(remark) remark = values(remark)
</insert> </insert>
<!-- <!--
单独更新固定标签。固定标签由 tags-view 的 PIN_VIEW / UNPIN_VIEW 高频触发, 确保设置行存在:不存在则插入默认行,已存在则**什么都不改**。
与登录跳转是两条独立的写入路径: 固定标签的增量写入要在一行上做「锁定读 → 改 → 写」,行必须先存在。
若合并进 upsert,保存登录跳转时会把固定标签一并覆盖成默认值。 刻意不用 upsert:它的 ON DUPLICATE 子句会覆盖 login_redirect / query_gateway_child,
故只在确实传入 pinnedTags 时才更新该列。 把用户已保存的设置冲回默认值;这里用 user_id = user_id 作为 no-op 更新。
-->
<insert id="insertDefaultIfAbsent">
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
</insert>
<!--
锁定读固定标签。固定标签的写入是「读-改-写」,必须靠行锁把并发请求串行化:
没有 FOR UPDATE 时两个并发请求各自读到同一份旧值,后写的会把先写的覆盖掉——
这正是原先「前端全量 PUT」丢标签的服务端成因。
-->
<select id="selectPinnedTagsForUpdate" resultType="java.lang.String">
select pinned_tags
from sys_user_setting
where user_id = #{userId}
for update
</select>
<!--
整列覆盖写固定标签。与登录跳转是两条独立写入路径:若合并进 upsert,
保存登录跳转时会把固定标签一并覆盖成默认值。
**只在 addPinnedTag / removePinnedTag 的事务内调用**,且在 SELECT ... FOR UPDATE
之后落下,入参是锁内读出并改好的完整数组;对外不再有全量覆盖写的入口。
--> -->
<update id="updatePinnedTags" parameterType="com.fastbee.system.domain.SysUserSetting"> <update id="updatePinnedTags" parameterType="com.fastbee.system.domain.SysUserSetting">
update sys_user_setting update sys_user_setting

View File

@ -17,6 +17,12 @@
-- 现改为落库,同一账号在任何设备登录都能恢复固定区。 -- 现改为落库,同一账号在任何设备登录都能恢复固定区。
-- * 只存用户右键固定的 pinned 集合;路由自身声明的 affix(首页,router/index.js:80) -- * 只存用户右键固定的 pinned 集合;路由自身声明的 affix(首页,router/index.js:80)
-- 由 constantRoutes 天然重建,不入此列。 -- 由 constantRoutes 天然重建,不入此列。
-- * **写入语义:单条增量**。接口是 POST/DELETE 一个 path,服务端在事务里
-- 「SELECT ... FOR UPDATE → 读数组 → 改 → UPDATE 整列」。
-- 刻意**不提供提交整份列表的覆盖写接口**:前端手里那份列表可能已被
-- 另一个标签页/另一台设备改过,整份提交会把对方的改动静默抹掉,
-- 这正是固定标签「用着用着就少了」的成因。
-- * 增删都在 Java 侧按列表处理,**未使用任何 MySQL JSON 函数**。
-- * DEFAULT ('[]') 是 MySQL 8.0.13+ 的表达式默认值。TEXT 列不能带字面量 DEFAULT, -- * DEFAULT ('[]') 是 MySQL 8.0.13+ 的表达式默认值。TEXT 列不能带字面量 DEFAULT,
-- 不给默认值时 STRICT_TRANS_TABLES 下会直接报错,故必须显式写空数组。 -- 不给默认值时 STRICT_TRANS_TABLES 下会直接报错,故必须显式写空数组。
-- 低版本数据库请改为 VARCHAR(2000) NOT NULL DEFAULT '[]'。 -- 低版本数据库请改为 VARCHAR(2000) NOT NULL DEFAULT '[]'。
@ -26,12 +32,27 @@
-- ADD COLUMN `pinned_tags` text NOT NULL DEFAULT ('[]') -- ADD COLUMN `pinned_tags` text NOT NULL DEFAULT ('[]')
-- COMMENT '任务栏固定标签JSON数组,按序存 path;空数组表示未固定' -- COMMENT '任务栏固定标签JSON数组,按序存 path;空数组表示未固定'
-- AFTER `login_redirect`; -- 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` ( 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 '设置主键',
`user_id` bigint(20) NOT NULL COMMENT '用户ID(sys_user.user_id)', `user_id` bigint(20) NOT NULL COMMENT '用户ID(sys_user.user_id)',
`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是)',
`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

@ -33,11 +33,22 @@ export function getPinnedTags() {
}) })
} }
// 保存当前用户固定的任务栏标签 // 固定一个任务栏标签(服务端幂等追加到固定区末尾)
export function updatePinnedTags(paths) { // 刻意不做「整份列表覆盖写」:前端持有的列表可能已被别的标签页改过,
// 整份提交会把对方的改动静默抹掉
export function addPinnedTag(path) {
return request({ return request({
url: '/system/userSetting/pinnedTags', url: '/system/userSetting/pinnedTags',
method: 'put', method: 'post',
data: paths params: { path }
})
}
// 取消固定一个任务栏标签(服务端幂等移除)
export function removePinnedTag(path) {
return request({
url: '/system/userSetting/pinnedTags',
method: 'delete',
params: { path }
}) })
} }

View File

@ -77,6 +77,8 @@
</template> </template>
<script> <script>
import { Message } from 'element-ui'
import { getPinnedTags } from '@/api/system/userSetting'
import ScrollPane from './ScrollPane' import ScrollPane from './ScrollPane'
import path from 'path' import path from 'path'
@ -283,7 +285,8 @@ export default {
} }
// 2. 从后端恢复「固定到任务栏」的标签,跨浏览器/跨设备生效。 // 2. 从后端恢复「固定到任务栏」的标签,跨浏览器/跨设备生效。
// 接口异步,必须等返回后再补建固定区,否则 mounted 阶段读到空列表就再也不会重建。 // 接口异步,必须等返回后再补建固定区,否则 mounted 阶段读到空列表就再也不会重建。
this.$store.dispatch('tagsView/loadPinnedViews').then(paths => { getPinnedTags().then(res => {
const paths = Array.isArray(res.data) ? res.data : []
paths paths
// 路由里已不存在该 path(菜单被删/改名)时丢弃,避免产生打不开的死标签 // 路由里已不存在该 path(菜单被删/改名)时丢弃,避免产生打不开的死标签
.map(path => this.findRouteByPath(path)) .map(path => this.findRouteByPath(path))
@ -293,10 +296,18 @@ export default {
if (!route.name || affixPaths.indexOf(fullPath) !== -1) { if (!route.name || affixPaths.indexOf(fullPath) !== -1) {
return return
} }
// 必须走 PIN_VIEW,不能用 addView:mounted 阶段 addTags 已经把当前页作为
// 普通标签加进来了,而 ADD_VISITED_VIEW 是「路径已存在就跳过」——若当前页
// 恰好是个固定标签,恢复会被整个跳过,它就永远停在普通标签状态
// (图钉消失、出现关闭按钮);它又是 push 到数组末尾(普通标签之后),
// 会把固定区拆散,顺序随「当前停在哪一页」随机变化。
// PIN_VIEW 会把标签提升为固定标签并插到固定区末尾,于是按后端顺序依次恢复,
// 固定区顺序恒等于库里的顺序。
// 用 commit 而非 dispatch('pinView'):恢复过程绝不能触发落库。
// 只取重建标签所需的字段,不要把路由定义上的 component/children 也塞进 visitedViews; // 只取重建标签所需的字段,不要把路由定义上的 component/children 也塞进 visitedViews;
// query/params 一律用空对象:固定标签来自右键菜单,天然不带参数, // query/params 一律用空对象:固定标签来自右键菜单,天然不带参数,
// 且 RouterVo.query 是菜单的默认参数 JSON 字符串,直接透传会被 vue-router 当成非法 query // 且 RouterVo.query 是菜单的默认参数 JSON 字符串,直接透传会被 vue-router 当成非法 query
this.$store.dispatch('tagsView/addView', { this.$store.commit('tagsView/PIN_VIEW', {
path: fullPath, path: fullPath,
fullPath: fullPath, fullPath: fullPath,
name: route.name, name: route.name,
@ -305,6 +316,11 @@ export default {
meta: Object.assign({}, route.meta, { affix: true, pinned: true }) meta: Object.assign({}, route.meta, { affix: true, pinned: true })
}) })
}) })
}).catch(() => {
// 拉取失败就不重建固定区。库里数据不受影响(写入本来就是单条增量的),
// 用户重新固定一次即可;这里刻意不做任何清理动作,避免把好东西洗掉。
// 必须给提示,否则用户会以为固定标签又丢了
Message.warning('固定标签加载失败,刷新页面可重试')
}) })
}, },
/** /**

View File

@ -1,29 +1,12 @@
import { Message } from 'element-ui' import { Message } from 'element-ui'
import { getPinnedTags, updatePinnedTags } from '@/api/system/userSetting' import { addPinnedTag, removePinnedTag } from '@/api/system/userSetting'
// 固定标签的持久化说明: // 固定标签的持久化说明:
// 原实现把标签写入浏览器 localStorage(key: pinned-tags),只能在本机本浏览器生效, // 固定项落库(sys_user_setting.pinned_tags),按 path 存、顺序即固定区顺序,
// 换浏览器或换电脑就丢。现改为落库(sys_user_setting.pinned_tags), // 同一账号在任何设备登录都能恢复固定区。
// 固定项按 path 存取,顺序即固定区顺序。 // 写入走**服务端单条增量**:固定/取消固定各提交一个 path,前端不再提交自己那份列表,
// 本模块不再直接依赖 localStorage——组件初始化时从后端拉取,由 TagsView 触发。 // 因此不存在「拿过期快照整份覆盖」的丢标签问题,多标签页/多端同时操作也不会互相覆盖。
// 服务端在事务里「锁定读 → 改 → 写」,顺序由追加先后决定,与这里的固定区顺序一致。
// 只持久化「重新打开标签」所需的最小字段,避免把 meta 里的临时/函数数据写进 localStorage
function toStorableView(view) {
return {
path: view.path,
fullPath: view.fullPath,
name: view.name,
query: view.query || {},
params: view.params || {},
title: view.title || (view.meta && view.meta.title) || 'no-name',
meta: Object.assign({}, view.meta, { affix: true, pinned: true })
}
}
// 落库时只提交 path 列表,其余字段可由路由表还原,避免把冗余的 title/icon 存进库
function toPinnedPaths(list) {
return (list || []).map(v => v.path).filter(p => !!p)
}
// 固定区 = 排在普通标签之前的 affix 标签;新固定/取消固定的标签插到固定区末尾 // 固定区 = 排在普通标签之前的 affix 标签;新固定/取消固定的标签插到固定区末尾
function moveToAffixBlockEnd(list, view) { function moveToAffixBlockEnd(list, view) {
@ -31,13 +14,43 @@ function moveToAffixBlockEnd(list, view) {
list.splice(affixCount, 0, view) list.splice(affixCount, 0, view)
} }
// 把标签提升为固定标签并插到固定区末尾:已在标签栏里的原地提升,不在的新建。
//
// 「用户主动固定」和「刷新后按后端列表恢复」必须共用这段——两者都不能走 ADD_VISITED_VIEW:
// 1) 它是「路径已存在就跳过」。mounted 时 addTags 已经把当前页作为普通标签加进来了,
// 若当前页恰好是个固定标签,恢复会被整个跳过,它就一直停在普通标签状态
// (图钉消失、出现关闭按钮),现象是"每次刷新恰好有一个固定标签掉队";
// 2) 它是 push 到整个数组末尾(普通标签之后),而固定区必须排在最左,
// 于是固定区被拆散,顺序随"当前停在哪一页"随机变化。
// 走这里则按后端顺序逐个插到固定区末尾,最终固定区顺序恒等于库里的顺序。
function pinIntoAffixBlock(state, view) {
const index = state.visitedViews.findIndex(v => v.path === view.path)
const existing = index === -1 ? null : state.visitedViews[index]
// 已经是固定标签:只补标记、保持原位置。少了这一步,重复恢复(或恢复与主动固定撞在一起)
// 会把标签一次次往固定区末尾挪,顺序会漂
if (existing && existing.meta && existing.meta.affix) {
existing.meta = Object.assign({}, existing.meta, view.meta, { affix: true, pinned: true })
return existing
}
// affix=true 复用现有关闭逻辑:不显示关闭按钮、关闭其他/全部关闭时都保留
const target = existing || Object.assign({}, view, {
title: (view.meta && view.meta.title) || 'no-name'
})
target.meta = Object.assign({}, target.meta, view.meta, { affix: true, pinned: true })
if (existing) {
state.visitedViews.splice(index, 1)
}
moveToAffixBlockEnd(state.visitedViews, target)
return target
}
// 本模块刻意不保存「我固定了哪些标签」这份列表:
// 权威列表在服务端,本地只保留 visitedViews 上的 affix/pinned 标记用于渲染。
// 存一份本地列表就迟早会拿它去覆盖服务端(这正是原先丢标签的根源)。
const state = { const state = {
visitedViews: [], visitedViews: [],
cachedViews: [], cachedViews: [],
iframeViews: [], iframeViews: []
// 用户右键「固定标签到任务栏」的标签,刷新/换设备后仍然存在
// 初始为空,由 TagsView 在拿到后端数据后通过 SET_PINNED_VIEWS 填充
pinnedViews: []
} }
const mutations = { const mutations = {
@ -103,27 +116,28 @@ const mutations = {
state.cachedViews = [] state.cachedViews = []
}, },
UPDATE_VISITED_VIEW: (state, view) => { UPDATE_VISITED_VIEW: (state, view) => {
for (let v of state.visitedViews) { for (const v of state.visitedViews) {
if (v.path === view.path) { if (v.path !== view.path) continue
v = Object.assign(v, view) // 传入的 view 通常是 this.$route,它的 meta 直接来自路由表;
// 而 affix/pinned 是运行时才挂上去的标记(路由表里只有首页自带 affix)。
// 若整体 Object.assign(v, view),固定标记会被路由表的 meta 冲掉,
// 固定标签当场退化成「可关闭、会被挤进更多」的普通标签——必须先把旧标记摘出来。
const keptFlags = {}
if (v.meta && v.meta.affix) keptFlags.affix = true
if (v.meta && v.meta.pinned) keptFlags.pinned = true
Object.assign(v, view)
// 合并后 v.meta 已指向 $route.meta,要换成新对象再回填,避免污染路由表的 meta
v.meta = Object.assign({}, view.meta, keptFlags)
break break
} }
}
}, },
// 把标签置为「固定到任务栏」:已在标签栏的原地提升、不在的新建,统一插到固定区末尾。
// 「用户右键固定」与「刷新后按后端列表恢复」共用它——两者的唯一差别是要不要落库,
// 那由调用方区分:用户操作走 dispatch('pinView'),恢复直接 commit 本 mutation。
PIN_VIEW: (state, view) => { PIN_VIEW: (state, view) => {
const index = state.visitedViews.findIndex(v => v.path === view.path) pinIntoAffixBlock(state, view)
if (index === -1) return
const target = state.visitedViews[index]
// affix=true 复用现有关闭逻辑:不显示关闭按钮、关闭其他/全部关闭时都保留
target.meta = Object.assign({}, target.meta, { affix: true, pinned: true })
state.visitedViews.splice(index, 1)
moveToAffixBlockEnd(state.visitedViews, target)
if (!state.pinnedViews.some(v => v.path === view.path)) {
state.pinnedViews.push(toStorableView(target))
}
}, },
UNPIN_VIEW: (state, view) => { UNPIN_VIEW: (state, view) => {
state.pinnedViews = state.pinnedViews.filter(v => v.path !== view.path)
const index = state.visitedViews.findIndex(v => v.path === view.path) const index = state.visitedViews.findIndex(v => v.path === view.path)
if (index === -1) return if (index === -1) return
const target = state.visitedViews[index] const target = state.visitedViews[index]
@ -132,10 +146,6 @@ const mutations = {
state.visitedViews.splice(index, 1) state.visitedViews.splice(index, 1)
moveToAffixBlockEnd(state.visitedViews, target) moveToAffixBlockEnd(state.visitedViews, target)
}, },
// 从后端载入固定标签,tagsView 初始化时调用
SET_PINNED_VIEWS: (state, list) => {
state.pinnedViews = Array.isArray(list) ? list : []
},
// 拖拽排序:把 visitedViews 里的 fromIndex 挪到 toIndex(toIndex 是「最终落在」的位置) // 拖拽排序:把 visitedViews 里的 fromIndex 挪到 toIndex(toIndex 是「最终落在」的位置)
SORT_VISITED_VIEWS: (state, { fromIndex, toIndex }) => { SORT_VISITED_VIEWS: (state, { fromIndex, toIndex }) => {
const list = state.visitedViews const list = state.visitedViews
@ -203,43 +213,28 @@ const actions = {
commit('ADD_VISITED_VIEW', view) commit('ADD_VISITED_VIEW', view)
}, },
/** /**
* 从后端载入该用户固定的任务栏标签 * 固定到任务栏
* *
* 落库后固定项不再依赖 localStorage,换浏览器/换电脑登录同一账号即可恢复。 * 本地先变、再落库;落库失败就回滚本地状态并提示,避免「界面显示已固定、实际没存上」。
* 失败时保持空列表,不阻塞标签栏渲染。 * 提交的是**单个 path**——服务端按增量写入,不会碰到其它标签页/其它设备刚做的改动,
* 因此这里不需要先拉一次全量列表,也就不用担心那份列表已经过期。
*/ */
loadPinnedViews({ commit }) { pinView({ commit }, view) {
return getPinnedTags() commit('PIN_VIEW', view)
.then(res => { return addPinnedTag(view.path).catch(() => {
const paths = Array.isArray(res.data) ? res.data : [] commit('UNPIN_VIEW', view)
// 只保留 path,完整 view 由 TagsView 用当前路由表补全 Message.warning('固定标签保存失败,请重试')
commit('SET_PINNED_VIEWS', paths.map(p => ({ path: p })))
return paths
})
.catch(() => {
commit('SET_PINNED_VIEWS', [])
return []
}) })
}, },
/** /**
* 固定/取消固定后把最新 path 列表落库 * 取消固定
*
* 失败时只提示不打断操作:内存中的标签区已经变了,
* 用户不至于因为一次网络抖动失去反馈。
*/ */
persistPinnedViews({ state }) { unpinView({ commit }, view) {
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) commit('UNPIN_VIEW', view)
return dispatch('persistPinnedViews') return removePinnedTag(view.path).catch(() => {
commit('PIN_VIEW', view)
Message.warning('取消固定保存失败,请重试')
})
}, },
addCachedView({ commit }, view) { addCachedView({ commit }, view) {
commit('ADD_CACHED_VIEW', view) commit('ADD_CACHED_VIEW', view)

View File

@ -0,0 +1,312 @@
<template>
<div class="batch-serial-input">
<el-input
v-model="text"
:placeholder="placeholder"
:disabled="disabled"
:style="{ width: width }"
class="batch-serial-input__field"
@input="handleInput"
@change="handleChange"
@paste.native="handlePaste"
>
<i
slot="suffix"
class="el-icon-edit-outline batch-serial-input__edit"
title="批量编辑"
@click.stop="openDialog"
></i>
</el-input>
<el-dialog
title="批量添加"
:visible.sync="visible"
append-to-body
width="600px"
:close-on-click-modal="false"
@open="handleDialogOpen"
>
<div class="batch-serial-input__tips">
<p class="batch-serial-input__tips-main">
不同设备编号间分隔仅支持:换行、逗号、空格
<span class="batch-serial-input__tips-warn">(可混用)</span>
</p>
<p class="batch-serial-input__tips-sub">或从 Excel 中粘贴复制</p>
</div>
<el-input
ref="textarea"
v-model="draft"
type="textarea"
:rows="12"
resize="none"
class="batch-serial-input__textarea"
@input="handleDraftInput"
/>
<div class="batch-serial-input__invalid">
<span class="batch-serial-input__invalid-label">无效数据:</span>
<span class="batch-serial-input__invalid-value">{{ invalidText }}</span>
</div>
<div slot="footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="handleConfirm">确定</el-button>
<el-button type="primary" @click="handleCheck">检查数据</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
/**
* 批量设备编号输入框
*
* <p>输入框本身可直接输入(支持中英文逗号、空格混用),失焦/回车时统一归一化为
* 英文逗号分隔并向上回传,与后端 /iot/device/shortList 的 serialNumber 约定一致;
* 输入框内的编辑图标另开弹框,用于从 Excel 批量粘贴与格式校验。
*
* <p>刻意不使用 el-input 的 clearable:suffix 是 inline 且右对齐,清空图标出现时
* 会把编辑图标往左推;而字段通常只有 150px 宽,两个图标会挤掉占位文字。
* 清空可直接全选删除,或在弹框里清空后确认。
*
* <p>校验口径(仅前端,且只在弹框内执行):非空、字符合法、去重。
* 直接输入时只做分隔符归一化 + 去重,不因非法字符丢数据,避免打断输入。
*/
/** 分隔符:英文逗号 / 中文逗号 / 任意空白(含换行、制表符) */
const SEPARATOR = /[,,\s]+/;
/** 设备编号字符规则,与 device-edit.vue 的校验保持一致 */
const SERIAL_NUMBER_PATTERN = /^[0-9a-zA-Z]+$/;
export default {
name: 'BatchSerialNumberInput',
props: {
// 英文逗号分隔的编号串
value: {
type: String,
default: ''
},
placeholder: {
type: String,
default: '请输入设备编号'
},
width: {
type: String,
default: '220px'
},
disabled: {
type: Boolean,
default: false
}
},
data() {
return {
visible: false,
// 输入框的本地文本:允许用户按原样输入(含中文逗号、换行),失焦时再归一化
text: '',
// 打开弹框时优先用它回填(多行粘贴场景),否则用 this.text
pendingDraft: null,
draft: '',
invalidList: []
};
},
computed: {
invalidText() {
return this.invalidList.join(',');
}
},
watch: {
value(val) {
// 仅当「外部值」与「本地文本归一化结果」不一致时才同步,
// 否则用户输入 "A,"(归一化后为 "A")会被父组件回写打断,逗号打不出来。
if (this.normalize(this.text) !== this.normalize(val)) {
this.text = val || '';
}
}
},
created() {
this.text = this.value || '';
},
methods: {
/** 按分隔符切分为非空 token(不做合法性校验、不去重) */
splitTokens(raw) {
return (raw || '')
.split(SEPARATOR)
.map((item) => item.trim())
.filter((item) => item);
},
/** 归一化:任意分隔符 → 英文逗号,并去重保序 */
normalize(raw) {
const seen = {};
return this.splitTokens(raw)
.filter((token) => {
if (seen[token]) {
return false;
}
seen[token] = true;
return true;
})
.join(',');
},
/**
* 拆分并校验
*
* @param {String} raw 原始输入
* @returns {{valid: String[], invalid: String[]}} 合法(去重保序)与非法编号
*/
parse(raw) {
const valid = [];
const invalid = [];
const seen = {};
this.splitTokens(raw).forEach((token) => {
if (!SERIAL_NUMBER_PATTERN.test(token)) {
invalid.push(token);
return;
}
if (seen[token]) {
return;
}
seen[token] = true;
valid.push(token);
});
return { valid, invalid };
},
/** 输入过程中只同步 v-model,不触发 change(change 会触发列表查询,逐字符发请求) */
handleInput() {
this.$emit('input', this.normalize(this.text));
},
/** 失焦 / 回车:把输入框内容归一化后落回显示,再通知外部 */
handleChange() {
const normalized = this.normalize(this.text);
this.text = normalized;
this.$emit('input', normalized);
this.$emit('change', normalized);
},
/**
* 打开弹框
*
* @param {String} [draftSource] 可选的回填来源;不传(如点击图标时传入 MouseEvent)则用输入框当前内容
*/
openDialog(draftSource) {
if (this.disabled) {
return;
}
this.pendingDraft = typeof draftSource === 'string' ? draftSource : null;
this.visible = true;
},
/**
* 多行粘贴拦截
*
* <p>单行 input 的 value 会被浏览器剔除换行符(HTML 规范行为),
* 直接粘贴 Excel 多列会导致 "A"\n"B" 被拼成 "AB" —— 两个编号合成一个非法编号。
* 因此含换行的粘贴一律转交弹框,由用户核对后再确认。
*/
handlePaste(event) {
const clipboard = event.clipboardData || window.clipboardData;
const pasted = clipboard ? clipboard.getData('text') : '';
if (!pasted || !/[\r\n]/.test(pasted)) {
return;
}
event.preventDefault();
this.openDialog([this.text, pasted].filter((item) => item).join('\n'));
},
handleDialogOpen() {
// 回填时每行一个编号:粘贴内容常来自 Excel 多行,逐行展示便于核对
const source = this.pendingDraft === null ? this.text : this.pendingDraft;
this.draft = this.splitTokens(source).join('\n');
this.pendingDraft = null;
this.invalidList = [];
},
handleDraftInput() {
// 内容变化后清空上次的校验结果,避免展示过期结论
this.invalidList = [];
},
handleCheck() {
this.invalidList = this.parse(this.draft).invalid;
if (this.invalidList.length === 0) {
this.$modal.msgSuccess('数据校验通过');
}
},
handleConfirm() {
const { valid, invalid } = this.parse(this.draft);
if (invalid.length > 0) {
this.invalidList = invalid;
this.$modal.msgError('存在无效数据,请修正或移除后再提交');
return;
}
const normalized = valid.join(',');
this.visible = false;
this.text = normalized;
this.$emit('input', normalized);
this.$emit('change', normalized);
}
}
};
</script>
<style lang="scss" scoped>
.batch-serial-input {
display: inline-block;
// 让 suffix 内的图标随输入框高度垂直居中(Element 只给 .el-input__icon 定了 line-height,
// 我们用的是自己的 class,不套用它的 40px 行高,否则 mini/small 尺寸下会偏下)
&__field ::v-deep .el-input__suffix-inner {
display: inline-flex;
align-items: center;
height: 100%;
}
&__edit {
cursor: pointer;
color: #c0c4cc;
font-size: 15px;
transition: color 0.2s;
&:hover {
color: #409eff;
}
}
&__tips {
margin-bottom: 10px;
&-main {
margin: 0;
font-size: 14px;
color: #303133;
}
&-warn {
color: #f56c6c;
}
&-sub {
margin: 6px 0 0;
font-size: 14px;
color: #303133;
}
}
&__textarea ::v-deep .el-textarea__inner {
font-family: Consolas, Monaco, monospace;
}
&__invalid {
margin-top: 8px;
min-height: 20px;
font-size: 13px;
line-height: 20px;
&-label {
color: #f56c6c;
}
&-value {
color: #f56c6c;
word-break: break-all;
}
}
}
</style>

View File

@ -31,6 +31,14 @@
<el-option v-for="group in myGroupList" :key="group.groupId" :label="group.groupName" :value="group.groupId" /> <el-option v-for="group in myGroupList" :key="group.groupId" :label="group.groupName" :value="group.groupId" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="设备编号" class="history-device-filter__item">
<batch-serial-number-input
v-model="historyDeviceFilter.serialNumber"
placeholder="请输入设备编号"
width="340px"
@change="handleHistoryDeviceFilterChange"
/>
</el-form-item>
<el-form-item class="history-device-filter__item history-device-filter__item--checkbox"> <el-form-item class="history-device-filter__item history-device-filter__item--checkbox">
<el-checkbox v-model="historyDeviceFilter.showChild" @change="handleHistoryDeviceFilterChange"> <el-checkbox v-model="historyDeviceFilter.showChild" @change="handleHistoryDeviceFilterChange">
{{ $t('device.index.105953-18') }} {{ $t('device.index.105953-18') }}
@ -287,6 +295,7 @@
<script> <script>
import moment from 'moment'; import moment from 'moment';
import { listDeviceShort, listThingsModel } from '@/api/iot/device.js'; import { listDeviceShort, listThingsModel } from '@/api/iot/device.js';
import BatchSerialNumberInput from '@/views/components/BatchSerialNumberInput';
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 { listGateway } from '@/api/iot/gateway.js'; import { listGateway } from '@/api/iot/gateway.js';
@ -306,6 +315,7 @@ import { blobValidate } from '@/utils/ruoyi';
export default { export default {
name: 'dataCenterHistory', name: 'dataCenterHistory',
components: { BatchSerialNumberInput },
data() { data() {
return { return {
activeTab: 'device', activeTab: 'device',
@ -322,6 +332,8 @@ export default {
deptId: null, deptId: null,
showChild: true, showChild: true,
groupId: null, groupId: null,
/** 英文逗号分隔的设备编号,支持一次填多个;为空则不过滤 */
serialNumber: null,
}, },
deptOptions: [], deptOptions: [],
myGroupList: [], myGroupList: [],
@ -750,6 +762,9 @@ export default {
if (this.historyDeviceFilter.groupId) { if (this.historyDeviceFilter.groupId) {
params.groupId = this.historyDeviceFilter.groupId; params.groupId = this.historyDeviceFilter.groupId;
} }
if (this.historyDeviceFilter.serialNumber) {
params.serialNumber = this.historyDeviceFilter.serialNumber;
}
listDeviceShort(params).then((res) => { listDeviceShort(params).then((res) => {
if (res.code === 200) { if (res.code === 200) {
this.devDeviceList = res.rows; this.devDeviceList = res.rows;

View File

@ -6,7 +6,12 @@
<el-input v-model="queryParams.deviceName" :placeholder="$t('device.index.105953-1')" clearable @keyup.enter.native="handleQuery" style="width: 150px" /> <el-input v-model="queryParams.deviceName" :placeholder="$t('device.index.105953-1')" clearable @keyup.enter.native="handleQuery" style="width: 150px" />
</el-form-item> </el-form-item>
<el-form-item :label="$t('device.index.105953-2')" prop="serialNumber"> <el-form-item :label="$t('device.index.105953-2')" prop="serialNumber">
<el-input v-model="queryParams.serialNumber" :placeholder="$t('device.index.105953-3')" clearable @keyup.enter.native="handleQuery" style="width: 150px" /> <batch-serial-number-input
v-model="queryParams.serialNumber"
:placeholder="$t('device.index.105953-3')"
width="150px"
@keyup.enter.native="handleQuery"
/>
</el-form-item> </el-form-item>
<el-form-item :label="$t('device.index.105953-4')" prop="status"> <el-form-item :label="$t('device.index.105953-4')" prop="status">
<el-select v-model="queryParams.status" :placeholder="$t('device.index.105953-5')" clearable style="width: 150px"> <el-select v-model="queryParams.status" :placeholder="$t('device.index.105953-5')" clearable style="width: 150px">
@ -405,6 +410,7 @@ import allotImport from './allot-import-dialog';
import importRecord from './import-record'; import importRecord from './import-record';
import recycleRecord from './recycle-record'; import recycleRecord from './recycle-record';
import allotRecord from './allot-record'; import allotRecord from './allot-record';
import BatchSerialNumberInput from '@/views/components/BatchSerialNumberInput';
export default { export default {
name: 'Device', name: 'Device',
@ -417,6 +423,7 @@ export default {
importRecord, importRecord,
recycleRecord, recycleRecord,
allotRecord, allotRecord,
BatchSerialNumberInput,
}, },
data() { data() {
return { return {

View File

@ -9,7 +9,12 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="设备编号" prop="serialNumber"> <el-form-item label="设备编号" prop="serialNumber">
<el-input v-model="queryParams.serialNumber" placeholder="主设备/子设备编号" clearable style="width:170px" @keyup.enter.native="handleQuery" /> <batch-serial-number-input
v-model="queryParams.serialNumber"
placeholder="主设备/子设备编号"
width="170px"
@keyup.enter.native="handleQuery"
/>
</el-form-item> </el-form-item>
<el-form-item label="设备类型" prop="sensorType"> <el-form-item label="设备类型" prop="sensorType">
<el-select v-model="queryParams.sensorType" placeholder="全部" clearable filterable allow-create default-first-option style="width:140px"> <el-select v-model="queryParams.sensorType" placeholder="全部" clearable filterable allow-create default-first-option style="width:140px">
@ -354,11 +359,13 @@ import { listPlatformFlow, getPlatformFlow } from '@/api/platform/flow'
import { listDevice, listThingsModel, getDevice, listDeviceShort } from '@/api/iot/device' import { listDevice, listThingsModel, getDevice, listDeviceShort } from '@/api/iot/device'
import { listGateway } from '@/api/iot/gateway' import { listGateway } from '@/api/iot/gateway'
import { getDicts } from '@/api/system/dict/data' import { getDicts } from '@/api/system/dict/data'
import BatchSerialNumberInput from '@/views/components/BatchSerialNumberInput'
const SENSOR_TYPE_DICT = 'platform_sensor_type' const SENSOR_TYPE_DICT = 'platform_sensor_type'
export default { export default {
name: 'PlatformDeviceMapping', name: 'PlatformDeviceMapping',
components: { BatchSerialNumberInput },
data() { data() {
return { return {
loading: false, submitLoading: false, showSearch: true, loading: false, submitLoading: false, showSearch: true,

View File

@ -17,6 +17,10 @@
</el-select> </el-select>
</setting-row> </setting-row>
<setting-row label="查询网关子设备" desc="按设备编号查询时生效:输入网关子设备编号会返回其所属网关设备;同时允许查到「网关子设备」类型的产品设备。不按编号查(默认列表)时不生效,列表始终不展示子设备。">
<el-switch v-model="form.queryGatewayChild" />
</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> <el-button icon="el-icon-refresh-left" @click="handleReset">重置</el-button>
@ -32,10 +36,11 @@ import {
} from '@/api/system/userSetting'; } from '@/api/system/userSetting';
import SettingRow from './SettingRow'; import SettingRow from './SettingRow';
// 默认值与后端 UserSettingConstants.DEFAULT_LOGIN_REDIRECT 保持一致: // 默认值与后端 UserSettingConstants 保持一致:
// 未指定的账号保持原有的数据大屏落地行为 // 未指定的账号保持原有的数据大屏落地行为;子设备开关默认关闭
const DEFAULT_FORM = { const DEFAULT_FORM = {
loginRedirect: '/bigscreen' loginRedirect: '/bigscreen',
queryGatewayChild: false
}; };
export default { export default {
@ -67,15 +72,22 @@ export default {
getSetting() { getSetting() {
getUserSetting().then((res) => { getUserSetting().then((res) => {
const data = res.data || {}; const data = res.data || {};
// 后端对未配置过的用户返回默认值,此处仅在值缺失时兜底 // 后端对未配置过的用户返回默认值,此处仅在值缺失时兜底。
// queryGatewayChild 不能用 ||:布尔 false 会被吞掉,导致开关回显错乱。
this.form = { this.form = {
loginRedirect: data.loginRedirect || DEFAULT_FORM.loginRedirect loginRedirect: data.loginRedirect || DEFAULT_FORM.loginRedirect,
queryGatewayChild: data.queryGatewayChild === null || data.queryGatewayChild === undefined
? DEFAULT_FORM.queryGatewayChild
: data.queryGatewayChild
}; };
}); });
}, },
submitForm() { submitForm() {
this.saving = true; this.saving = true;
updateUserSetting({ loginRedirect: this.form.loginRedirect }) updateUserSetting({
loginRedirect: this.form.loginRedirect,
queryGatewayChild: this.form.queryGatewayChild
})
.then(() => { .then(() => {
this.$modal.msgSuccess('保存成功,下次登录生效'); this.$modal.msgSuccess('保存成功,下次登录生效');
}) })