系统设置:支持查询子设备
This commit is contained in:
parent
e7ebf3b918
commit
fe8d204d11
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询登录落地页候选项(可作为页签的菜单)
|
* 查询登录落地页候选项(可作为页签的菜单)
|
||||||
*
|
*
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询任务栏固定标签
|
* 查询任务栏固定标签
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 '更新者',
|
||||||
|
|||||||
@ -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 }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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('固定标签加载失败,刷新页面可重试')
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
312
yack-web/src/views/components/BatchSerialNumberInput/index.vue
Normal file
312
yack-web/src/views/components/BatchSerialNumberInput/index.vue
Normal 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>
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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('保存成功,下次登录生效');
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user