系统设置:支持查询子设备
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.ApiOperation;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 个人系统设置
|
||||
*
|
||||
@ -57,10 +58,25 @@ public class SysUserSettingController extends BaseController {
|
||||
return AjaxResult.success(userSettingService.getPinnedTags(SecurityUtils.getUserId()));
|
||||
}
|
||||
|
||||
@ApiOperation("保存当前用户固定的任务栏标签")
|
||||
@PutMapping("/pinnedTags")
|
||||
public AjaxResult savePinnedTags(@RequestBody List<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();
|
||||
}
|
||||
|
||||
|
||||
@ -53,6 +53,22 @@ public class UserSettingConstants {
|
||||
*/
|
||||
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() {
|
||||
}
|
||||
|
||||
|
||||
@ -23,6 +23,9 @@ import com.fastbee.iot.model.SerialNumberVO;
|
||||
import com.fastbee.iot.model.dto.ThingsModelDTO;
|
||||
import com.fastbee.iot.model.gateWay.SubDeviceCreateByProductVO;
|
||||
import com.fastbee.iot.service.IDeviceService;
|
||||
import com.fastbee.iot.utils.SerialNumberUtils;
|
||||
import com.fastbee.system.domain.SysUserSetting;
|
||||
import com.fastbee.system.service.ISysUserSettingService;
|
||||
import com.github.pagehelper.PageInfo;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
@ -56,6 +59,9 @@ public class DeviceController extends BaseController {
|
||||
@Autowired
|
||||
private RedisCache redisCache;
|
||||
|
||||
@Autowired
|
||||
private ISysUserSettingService userSettingService;
|
||||
|
||||
/**
|
||||
* 查询设备列表
|
||||
*/
|
||||
@ -113,6 +119,16 @@ public class DeviceController extends BaseController {
|
||||
public TableDataInfo shortList(Device device) {
|
||||
startPage();
|
||||
LoginUser loginUser = getLoginUser();
|
||||
// 设备编号支持一次填多个(中英文逗号/换行/空格分隔),统一拆成去重后的 IN 查询集合;
|
||||
// 单个编号同样得到 size=1 的集合走 IN;serialNumber 为空时集合为空,SQL 不加编号条件。
|
||||
device.setSerialNumbers(SerialNumberUtils.split(device.getSerialNumber()));
|
||||
if (Objects.isNull(device.getQueryGatewayChild())) {
|
||||
// 未显式指定时,按当前登录用户的个人设置决定「按编号查询时是否查询网关子设备」:
|
||||
// 命中子设备编号则返回其所属网关,并放开「网关子设备(4)」产品过滤(仅按编号查时)。
|
||||
// 维度判据遵循「读该值时能否拿到 userId」:此处有 SecurityContext,故为 per-user 设置。
|
||||
SysUserSetting userSetting = userSettingService.selectByUserId(loginUser.getUserId());
|
||||
device.setQueryGatewayChild(userSetting != null && Boolean.TRUE.equals(userSetting.getQueryGatewayChild()));
|
||||
}
|
||||
if (null == loginUser.getDeptId()) {
|
||||
// 终端用户查询设备
|
||||
device.setTenantId(loginUser.getUserId());
|
||||
|
||||
@ -79,6 +79,18 @@ public class Device extends BaseEntity {
|
||||
@Excel(name = "设备编号")
|
||||
private String serialNumber;
|
||||
|
||||
/**
|
||||
* 设备编号集合(批量查询)
|
||||
*
|
||||
* <p>前端一次填入多个编号时,Controller 用 {@code SerialNumberUtils}
|
||||
* 把 {@link #serialNumber} 拆到本字段,Mapper 走 IN 查询;
|
||||
* 只有一个编号时不填充本字段,仍走 {@link #serialNumber} 的等值匹配。
|
||||
*/
|
||||
@Transient
|
||||
@ApiModelProperty("设备编号集合(批量查询用)")
|
||||
@TableField(exist = false)
|
||||
private List<String> serialNumbers;
|
||||
|
||||
/**
|
||||
* 基准点设备ID(结构安全监测用)
|
||||
*
|
||||
@ -288,6 +300,11 @@ public class Device extends BaseEntity {
|
||||
@TableField(exist = false)
|
||||
private Boolean showChild;
|
||||
|
||||
@Transient
|
||||
@ApiModelProperty("按编号查询时是否查询网关子设备:命中子设备编号返回其所属网关,并放开「网关子设备(4)」产品过滤(为空时由后端按当前用户的个人设置填充)")
|
||||
@TableField(exist = false)
|
||||
private Boolean queryGatewayChild;
|
||||
|
||||
@Transient
|
||||
@ApiModelProperty("查询入口(1=设备管理列表(需要查看更新时间) 2=数据联动、项目管理、设备绑定 3=全部设备)")
|
||||
@TableField(exist = false)
|
||||
|
||||
@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.fastbee.iot.domain.Device;
|
||||
import com.fastbee.iot.mapper.DeviceMapper;
|
||||
import com.fastbee.iot.platform.domain.*;
|
||||
import com.fastbee.iot.utils.SerialNumberUtils;
|
||||
import com.fastbee.iot.platform.flow.FlowContext;
|
||||
import com.fastbee.iot.platform.flow.PlatformFlowExecutor;
|
||||
import com.fastbee.iot.platform.mapper.PlatformDeviceMappingMapper;
|
||||
@ -51,16 +52,23 @@ public class PlatformDeviceMappingServiceImpl extends ServiceImpl<PlatformDevice
|
||||
|
||||
@Override
|
||||
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()
|
||||
.eq(query.getPlatformId() != null, PlatformDeviceMapping::getPlatformId, query.getPlatformId())
|
||||
.eq(query.getDeviceId() != null, PlatformDeviceMapping::getDeviceId, query.getDeviceId())
|
||||
.and(StringUtils.hasText(serialNumber), w -> w
|
||||
.like(PlatformDeviceMapping::getSerialNumber, serialNumber)
|
||||
.or()
|
||||
.apply("EXISTS (SELECT 1 FROM platform_device_sub_mapping s " +
|
||||
"WHERE s.parent_mapping_id = platform_device_mapping.id AND s.sub_serial_number LIKE {0})",
|
||||
"%" + serialNumber + "%"))
|
||||
.and(!serialNumbers.isEmpty(), w -> {
|
||||
for (int i = 0; i < serialNumbers.size(); i++) {
|
||||
if (i > 0) {
|
||||
w.or();
|
||||
}
|
||||
String pattern = "%" + serialNumbers.get(i) + "%";
|
||||
w.apply("(platform_device_mapping.serial_number LIKE {0} OR EXISTS (SELECT 1 FROM platform_device_sub_mapping s "
|
||||
+ "WHERE s.parent_mapping_id = platform_device_mapping.id AND s.sub_serial_number LIKE {1}))",
|
||||
pattern, pattern);
|
||||
}
|
||||
})
|
||||
.eq(StringUtils.hasText(query.getSensorType()), PlatformDeviceMapping::getSensorType, query.getSensorType())
|
||||
.eq(query.getNetStatus() != null, PlatformDeviceMapping::getNetStatus, query.getNetStatus())
|
||||
.eq(StringUtils.hasText(query.getEnable()), PlatformDeviceMapping::getEnable, query.getEnable())
|
||||
|
||||
@ -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>
|
||||
and d.gw_dev_code is null
|
||||
<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)
|
||||
</if>
|
||||
</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="productId != null "> and d.product_id = #{productId}</if>
|
||||
@ -441,7 +449,33 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
</foreach>
|
||||
</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="status != null "> and d.status = #{status}</if>
|
||||
<if test="isSimulate != null">and d.is_simulate = #{isSimulate}</if>
|
||||
@ -1027,8 +1061,16 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
<where>
|
||||
and d.gw_dev_code is null
|
||||
<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)
|
||||
</if>
|
||||
</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="productId != null "> and d.product_id = #{productId}</if>
|
||||
@ -1040,7 +1082,33 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
</foreach>
|
||||
</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="status != null "> and d.status = #{status}</if>
|
||||
<if test="isSimulate != null">and d.is_simulate = #{isSimulate}</if>
|
||||
|
||||
@ -45,6 +45,21 @@ public class SysUserSetting extends BaseEntity {
|
||||
@ApiModelProperty(value = "任务栏固定标签JSON数组")
|
||||
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() {
|
||||
}
|
||||
|
||||
@ -59,6 +74,7 @@ public class SysUserSetting extends BaseEntity {
|
||||
setting.setUserId(userId);
|
||||
setting.setLoginRedirect(UserSettingConstants.DEFAULT_LOGIN_REDIRECT);
|
||||
setting.setPinnedTags(UserSettingConstants.EMPTY_PINNED_TAGS);
|
||||
setting.setQueryGatewayChild(UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD);
|
||||
return setting;
|
||||
}
|
||||
|
||||
@ -94,6 +110,14 @@ public class SysUserSetting extends BaseEntity {
|
||||
this.pinnedTags = pinnedTags;
|
||||
}
|
||||
|
||||
public Boolean getQueryGatewayChild() {
|
||||
return queryGatewayChild;
|
||||
}
|
||||
|
||||
public void setQueryGatewayChild(Boolean queryGatewayChild) {
|
||||
this.queryGatewayChild = queryGatewayChild;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
|
||||
@ -101,6 +125,7 @@ public class SysUserSetting extends BaseEntity {
|
||||
.append("userId", getUserId())
|
||||
.append("loginRedirect", getLoginRedirect())
|
||||
.append("pinnedTags", getPinnedTags())
|
||||
.append("queryGatewayChild", getQueryGatewayChild())
|
||||
.append("createBy", getCreateBy())
|
||||
.append("createTime", getCreateTime())
|
||||
.append("updateBy", getUpdateBy())
|
||||
|
||||
@ -2,7 +2,9 @@ package com.fastbee.system.mapper;
|
||||
|
||||
import com.fastbee.system.domain.LoginRedirectOption;
|
||||
import com.fastbee.system.domain.SysUserSetting;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@ -29,15 +31,48 @@ public interface SysUserSettingMapper {
|
||||
int upsert(SysUserSetting setting);
|
||||
|
||||
/**
|
||||
* 仅更新固定标签列
|
||||
* 仅更新固定标签列(整列覆盖)
|
||||
*
|
||||
* <p>用户尚无记录时更新 0 行,调用方需自行判断是否改用 insert。
|
||||
* <p>只在 {@code addPinnedTag / removePinnedTag} 的「锁定读 → 改 → 写」事务内调用,
|
||||
* 入参是**锁内读出并改好的完整数组**。对外不再提供「全量覆盖写」的入口——
|
||||
* 那会让两个标签页各自拿旧快照互相覆盖,是丢固定标签的直接成因。
|
||||
*
|
||||
* @param setting 设置,userId 与 pinnedTags 必填
|
||||
* @return 影响行数
|
||||
*/
|
||||
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();
|
||||
|
||||
/**
|
||||
* 保存任务栏固定标签
|
||||
* 固定一个任务栏标签
|
||||
*
|
||||
* <p>只落用户右键固定的 pinned 集合;路由自身声明的 affix 不在其中。
|
||||
* <p>增量写入:已固定则原样返回(幂等,**不改动它的位置**),未固定则追加到固定区末尾。
|
||||
* 并发安全由服务端「锁定读 → 改 → 写」保证——多个标签页/多端同时固定不会互相覆盖,
|
||||
* 因为前端不再提交自己那份可能过期的完整列表。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param paths 固定标签的路由 path 列表,按固定顺序
|
||||
* @param path 要固定的路由 path
|
||||
* @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 org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
@ -35,7 +36,8 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService {
|
||||
* 缓存值的字段分隔符
|
||||
*
|
||||
* <p>用 \n 而不是常见的逗号/竖线:loginRedirect 是路由 path 不含换行,
|
||||
* pinnedTags 是 JSON 数组(元素为带引号的字符串)也不含裸换行。
|
||||
* pinnedTags 是 JSON 数组(元素为带引号的字符串)也不含裸换行,
|
||||
* queryGatewayChild 是布尔字面量,三段互不冲突。
|
||||
*/
|
||||
private static final String CACHE_SEPARATOR = "\n";
|
||||
|
||||
@ -55,7 +57,7 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService {
|
||||
// 缓存的是扁平字符串而非整个 POJO,理由同 SysConfigServiceImpl#selectConfigByKey:
|
||||
// 本项目 Redis 序列化器是 FastJson2 + SupportAutoType,直接缓存继承 BaseEntity
|
||||
// (含 Map<String,Object> params 等字段)的对象,反序列化存在取值不稳与
|
||||
// autoType 风险;缓存两个扁平字段可完全规避。
|
||||
// autoType 风险;缓存扁平字段可完全规避。
|
||||
String cached = redisCache.getStrCacheObject(cacheKey);
|
||||
if (StringUtils.isNotEmpty(cached)) {
|
||||
SysUserSetting fromCache = parseCached(cached, userId);
|
||||
@ -76,24 +78,31 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService {
|
||||
*/
|
||||
private void writeCache(String cacheKey, SysUserSetting setting) {
|
||||
redisCache.setStrCacheObject(cacheKey,
|
||||
setting.getLoginRedirect() + CACHE_SEPARATOR + setting.getPinnedTags());
|
||||
setting.getLoginRedirect() + CACHE_SEPARATOR
|
||||
+ setting.getPinnedTags() + CACHE_SEPARATOR
|
||||
+ setting.getQueryGatewayChild());
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析缓存值,格式非法时返回 null 走查库
|
||||
*
|
||||
* <p>段数必须严格等于 3:旧版本只缓存了 2 段(loginRedirect + pinnedTags),
|
||||
* 若按缺省值补齐会读到错误的 queryGatewayChild,故旧格式一律判为未命中,
|
||||
* 由查库回填新格式。缓存无 TTL,但每个用户最多回填一次。
|
||||
*
|
||||
* @param cached 缓存字符串
|
||||
* @param userId 用户ID
|
||||
* @return 设置对象,解析失败返回 null
|
||||
*/
|
||||
private SysUserSetting parseCached(String cached, Long userId) {
|
||||
int idx = cached.indexOf(CACHE_SEPARATOR);
|
||||
if (idx < 0) {
|
||||
String[] parts = cached.split(CACHE_SEPARATOR, -1);
|
||||
if (parts.length != 3) {
|
||||
return null;
|
||||
}
|
||||
SysUserSetting setting = SysUserSetting.defaultOf(userId);
|
||||
setting.setLoginRedirect(cached.substring(0, idx));
|
||||
setting.setPinnedTags(cached.substring(idx + 1));
|
||||
setting.setLoginRedirect(parts[0]);
|
||||
setting.setPinnedTags(parts[1]);
|
||||
setting.setQueryGatewayChild(Boolean.parseBoolean(parts[2]));
|
||||
return setting;
|
||||
}
|
||||
|
||||
@ -102,6 +111,10 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService {
|
||||
public int save(Long userId, SysUserSetting setting) {
|
||||
SysUserSetting target = SysUserSetting.defaultOf(userId);
|
||||
target.setLoginRedirect(setting.getLoginRedirect());
|
||||
// 未传时落到默认值:列约束为 NOT NULL,直接写 null 会在 STRICT 模式下报错
|
||||
target.setQueryGatewayChild(setting.getQueryGatewayChild() == null
|
||||
? UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD
|
||||
: setting.getQueryGatewayChild());
|
||||
target.setRemark(setting.getRemark());
|
||||
Date now = new Date();
|
||||
target.setUpdateBy(setting.getUpdateBy());
|
||||
@ -111,41 +124,79 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService {
|
||||
target.setCreateTime(now);
|
||||
|
||||
int rows = userSettingMapper.upsert(target);
|
||||
// 写库成功后立即刷缓存,避免读到旧值。
|
||||
// 回写时保留库中已有的 pinned_tags:upsert 的 ON DUPLICATE 子句不更新该列,
|
||||
// 内存对象却是默认值,若直接回填缓存会把用户的固定标签"洗"成空数组。
|
||||
SysUserSetting latest = userSettingMapper.selectByUserId(userId);
|
||||
writeCache(getCacheKey(userId), latest == null ? target : latest);
|
||||
// 写库成功后立即刷缓存。必须重查库再回填:upsert 的 ON DUPLICATE 子句不更新
|
||||
// pinned_tags,而内存对象 target 的 pinnedTags 是默认空数组,
|
||||
// 直接回填会把用户的固定标签"洗"成空。
|
||||
refreshCache(userId, target);
|
||||
return rows;
|
||||
}
|
||||
|
||||
@Override
|
||||
@DS("master")
|
||||
public void savePinnedTags(Long userId, List<String> paths, String operator) {
|
||||
String json = normalizePinnedTags(paths);
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void addPinnedTag(Long userId, String path, String operator) {
|
||||
if (!isValidPinnedPath(path)) {
|
||||
return;
|
||||
}
|
||||
Date now = new Date();
|
||||
// 行必须先存在,后面的锁定读才有东西可锁
|
||||
userSettingMapper.insertDefaultIfAbsent(userId, UserSettingConstants.DEFAULT_LOGIN_REDIRECT,
|
||||
UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD, operator, now);
|
||||
List<String> paths = parsePinnedTags(userSettingMapper.selectPinnedTagsForUpdate(userId));
|
||||
if (paths.contains(path)) {
|
||||
// 已固定:幂等返回。不重复追加,也不把它挪到末尾,避免重复点击改变固定区顺序
|
||||
return;
|
||||
}
|
||||
paths.add(path);
|
||||
writePinnedTags(userId, paths, operator, now);
|
||||
}
|
||||
|
||||
// 先判断记录是否存在,而不是靠 update 的影响行数:
|
||||
// MySQL 在「值未变化」时 affected_rows 同样为 0,与「记录不存在」无法区分,
|
||||
// 靠返回值判断会在重复提交同一份数据时误走 insert 分支。
|
||||
SysUserSetting exists = userSettingMapper.selectByUserId(userId);
|
||||
@Override
|
||||
@DS("master")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void removePinnedTag(Long userId, String path, String operator) {
|
||||
if (!isValidPinnedPath(path)) {
|
||||
return;
|
||||
}
|
||||
// 刻意不先建行:没有记录 = 本来就没固定过任何标签,
|
||||
// 取消固定不该凭空给用户创建一条设置记录
|
||||
List<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);
|
||||
patch.setPinnedTags(json);
|
||||
patch.setPinnedTags(JSON.toJSONString(paths));
|
||||
patch.setUpdateBy(operator);
|
||||
patch.setUpdateTime(now);
|
||||
|
||||
if (exists == null) {
|
||||
// 首次固定标签:用户还没有设置记录,走 upsert 而非裸 insert,避免并发下撞唯一键 1062
|
||||
patch.setLoginRedirect(UserSettingConstants.DEFAULT_LOGIN_REDIRECT);
|
||||
patch.setCreateBy(operator);
|
||||
patch.setCreateTime(now);
|
||||
userSettingMapper.upsert(patch);
|
||||
} else {
|
||||
userSettingMapper.updatePinnedTags(patch);
|
||||
refreshCache(userId, patch);
|
||||
}
|
||||
// 回缓存:整体重新查一次,保证与库内最终状态一致
|
||||
|
||||
/**
|
||||
* 重查一次并回填缓存,保证缓存与库内最终状态一致
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param fallback 重查返回 null 时的兜底对象(正常不会发生)
|
||||
*/
|
||||
private void refreshCache(Long userId, SysUserSetting fallback) {
|
||||
SysUserSetting latest = userSettingMapper.selectByUserId(userId);
|
||||
writeCache(getCacheKey(userId), latest == null ? patch : latest);
|
||||
writeCache(getCacheKey(userId), latest == null ? fallback : latest);
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -154,30 +205,19 @@ public class SysUserSettingServiceImpl implements ISysUserSettingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并序列化固定标签列表
|
||||
* 校验固定标签 path 是否合法
|
||||
*
|
||||
* <p>只接受站内绝对路径,去重且保持前端给定的固定顺序。
|
||||
* path 最终会进 router.push,混入外链会变成开放重定向入口。
|
||||
* <p>只接受站内绝对路径,且排除协议相对地址({@code //host})、
|
||||
* 带协议头的地址与路径穿越。path 最终会进 router.push,混入外链会变成开放重定向入口。
|
||||
*
|
||||
* @param paths 前端提交的 path 列表
|
||||
* @return JSON 数组字符串
|
||||
* @param path 待校验的路由 path
|
||||
* @return 合法返回 true
|
||||
*/
|
||||
private String normalizePinnedTags(List<String> paths) {
|
||||
List<String> valid = new ArrayList<>();
|
||||
if (paths != null) {
|
||||
for (String path : paths) {
|
||||
private boolean isValidPinnedPath(String path) {
|
||||
if (StringUtils.isEmpty(path) || !path.startsWith("/")) {
|
||||
continue;
|
||||
return false;
|
||||
}
|
||||
if (path.startsWith("//") || path.contains("://") || path.contains("..")) {
|
||||
continue;
|
||||
}
|
||||
if (!valid.contains(path)) {
|
||||
valid.add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
return JSON.toJSONString(valid);
|
||||
return !path.startsWith("//") && !path.contains("://") && !path.contains("..");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -9,6 +9,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
<result property="userId" column="user_id" />
|
||||
<result property="loginRedirect" column="login_redirect" />
|
||||
<result property="pinnedTags" column="pinned_tags" />
|
||||
<result property="queryGatewayChild" column="query_gateway_child" />
|
||||
<result property="createBy" column="create_by" />
|
||||
<result property="createTime" column="create_time" />
|
||||
<result property="updateBy" column="update_by" />
|
||||
@ -22,7 +23,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
</resultMap>
|
||||
|
||||
<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
|
||||
from sys_user_setting
|
||||
</sql>
|
||||
@ -37,24 +38,54 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
用 INSERT ... ON DUPLICATE KEY UPDATE 收敛「先查后插」的并发竞态:
|
||||
并发首次保存不会因唯一键冲突抛 DuplicateKey,也不需要 SELECT FOR UPDATE。
|
||||
create_time 保留首次写入时间,重复插入时不覆盖。
|
||||
pinned_tags 故意不在此更新:它由 tags-view 的独立写入路径维护,
|
||||
并入 upsert 会导致保存其它设置时把用户的固定标签冲成默认值。
|
||||
-->
|
||||
<insert id="upsert" parameterType="com.fastbee.system.domain.SysUserSetting">
|
||||
insert into sys_user_setting
|
||||
(user_id, login_redirect, pinned_tags, create_by, create_time, update_by, update_time, remark)
|
||||
(user_id, login_redirect, pinned_tags, query_gateway_child, create_by, create_time, update_by, update_time, remark)
|
||||
values
|
||||
(#{userId}, #{loginRedirect}, #{pinnedTags}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}, #{remark})
|
||||
(#{userId}, #{loginRedirect}, #{pinnedTags}, #{queryGatewayChild}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}, #{remark})
|
||||
on duplicate key update
|
||||
login_redirect = values(login_redirect),
|
||||
query_gateway_child = values(query_gateway_child),
|
||||
update_by = values(update_by),
|
||||
update_time = values(update_time),
|
||||
remark = values(remark)
|
||||
</insert>
|
||||
|
||||
<!--
|
||||
单独更新固定标签。固定标签由 tags-view 的 PIN_VIEW / UNPIN_VIEW 高频触发,
|
||||
与登录跳转是两条独立的写入路径:
|
||||
若合并进 upsert,保存登录跳转时会把固定标签一并覆盖成默认值。
|
||||
故只在确实传入 pinnedTags 时才更新该列。
|
||||
确保设置行存在:不存在则插入默认行,已存在则**什么都不改**。
|
||||
固定标签的增量写入要在一行上做「锁定读 → 改 → 写」,行必须先存在。
|
||||
刻意不用 upsert:它的 ON DUPLICATE 子句会覆盖 login_redirect / query_gateway_child,
|
||||
把用户已保存的设置冲回默认值;这里用 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 sys_user_setting
|
||||
|
||||
@ -17,6 +17,12 @@
|
||||
-- 现改为落库,同一账号在任何设备登录都能恢复固定区。
|
||||
-- * 只存用户右键固定的 pinned 集合;路由自身声明的 affix(首页,router/index.js:80)
|
||||
-- 由 constantRoutes 天然重建,不入此列。
|
||||
-- * **写入语义:单条增量**。接口是 POST/DELETE 一个 path,服务端在事务里
|
||||
-- 「SELECT ... FOR UPDATE → 读数组 → 改 → UPDATE 整列」。
|
||||
-- 刻意**不提供提交整份列表的覆盖写接口**:前端手里那份列表可能已被
|
||||
-- 另一个标签页/另一台设备改过,整份提交会把对方的改动静默抹掉,
|
||||
-- 这正是固定标签「用着用着就少了」的成因。
|
||||
-- * 增删都在 Java 侧按列表处理,**未使用任何 MySQL JSON 函数**。
|
||||
-- * DEFAULT ('[]') 是 MySQL 8.0.13+ 的表达式默认值。TEXT 列不能带字面量 DEFAULT,
|
||||
-- 不给默认值时 STRICT_TRANS_TABLES 下会直接报错,故必须显式写空数组。
|
||||
-- 低版本数据库请改为 VARCHAR(2000) NOT NULL DEFAULT '[]'。
|
||||
@ -26,12 +32,27 @@
|
||||
-- ADD COLUMN `pinned_tags` text NOT NULL DEFAULT ('[]')
|
||||
-- COMMENT '任务栏固定标签JSON数组,按序存 path;空数组表示未固定'
|
||||
-- AFTER `login_redirect`;
|
||||
-- ALTER TABLE `sys_user_setting`
|
||||
-- ADD COLUMN `query_gateway_child` tinyint(1) NOT NULL DEFAULT 0
|
||||
-- COMMENT '按编号查询时是否查网关子设备(0否1是)'
|
||||
-- AFTER `pinned_tags`;
|
||||
--
|
||||
-- query_gateway_child:开启后 /iot/device/shortList **按设备编号查询时**(serialNumber 非空):
|
||||
-- ① 命中的若是网关子设备编号,返回其所属的网关设备(子设备 gw_dev_code 存的就是网关 serial_number,
|
||||
-- 取 ifnull(gw_dev_code, serial_number) 换算);
|
||||
-- ② 放开产品类型为「网关子设备(4)」的过滤 —— 这类设备 gw_dev_code 为空、实为独立设备,
|
||||
-- 只是产品被标成 4,默认在列表中被隐藏(否则设备管理页会多出一大批),仅在按编号精确定位时可见。
|
||||
-- 该开关**不改变列表范围** —— gw_dev_code is null 恒生效,不带编号时 device_type != 4 依然过滤,
|
||||
-- 所以短列表始终只出顶层设备(网关 + 直连),既不出现挂在网关下的真子设备,
|
||||
-- 默认列表也不会多出 device_type=4 的设备。
|
||||
-- 默认 0。读该值的入口是 DeviceController#shortList,有 SecurityContext,故为 per-user 设置。
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `sys_user_setting` (
|
||||
`setting_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '设置主键',
|
||||
`user_id` bigint(20) NOT NULL COMMENT '用户ID(sys_user.user_id)',
|
||||
`login_redirect` varchar(128) NOT NULL DEFAULT '/bigscreen' COMMENT '登录后跳转路由path',
|
||||
`pinned_tags` text NOT NULL DEFAULT ('[]') COMMENT '任务栏固定标签JSON数组,按序存 path;空数组表示未固定',
|
||||
`query_gateway_child` tinyint(1) NOT NULL DEFAULT 0 COMMENT '按编号查询时是否查网关子设备(0否1是)',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建者',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新者',
|
||||
|
||||
@ -33,11 +33,22 @@ export function getPinnedTags() {
|
||||
})
|
||||
}
|
||||
|
||||
// 保存当前用户固定的任务栏标签
|
||||
export function updatePinnedTags(paths) {
|
||||
// 固定一个任务栏标签(服务端幂等追加到固定区末尾)
|
||||
// 刻意不做「整份列表覆盖写」:前端持有的列表可能已被别的标签页改过,
|
||||
// 整份提交会把对方的改动静默抹掉
|
||||
export function addPinnedTag(path) {
|
||||
return request({
|
||||
url: '/system/userSetting/pinnedTags',
|
||||
method: 'put',
|
||||
data: paths
|
||||
method: 'post',
|
||||
params: { path }
|
||||
})
|
||||
}
|
||||
|
||||
// 取消固定一个任务栏标签(服务端幂等移除)
|
||||
export function removePinnedTag(path) {
|
||||
return request({
|
||||
url: '/system/userSetting/pinnedTags',
|
||||
method: 'delete',
|
||||
params: { path }
|
||||
})
|
||||
}
|
||||
|
||||
@ -77,6 +77,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Message } from 'element-ui'
|
||||
import { getPinnedTags } from '@/api/system/userSetting'
|
||||
import ScrollPane from './ScrollPane'
|
||||
import path from 'path'
|
||||
|
||||
@ -283,7 +285,8 @@ export default {
|
||||
}
|
||||
// 2. 从后端恢复「固定到任务栏」的标签,跨浏览器/跨设备生效。
|
||||
// 接口异步,必须等返回后再补建固定区,否则 mounted 阶段读到空列表就再也不会重建。
|
||||
this.$store.dispatch('tagsView/loadPinnedViews').then(paths => {
|
||||
getPinnedTags().then(res => {
|
||||
const paths = Array.isArray(res.data) ? res.data : []
|
||||
paths
|
||||
// 路由里已不存在该 path(菜单被删/改名)时丢弃,避免产生打不开的死标签
|
||||
.map(path => this.findRouteByPath(path))
|
||||
@ -293,10 +296,18 @@ export default {
|
||||
if (!route.name || affixPaths.indexOf(fullPath) !== -1) {
|
||||
return
|
||||
}
|
||||
// 必须走 PIN_VIEW,不能用 addView:mounted 阶段 addTags 已经把当前页作为
|
||||
// 普通标签加进来了,而 ADD_VISITED_VIEW 是「路径已存在就跳过」——若当前页
|
||||
// 恰好是个固定标签,恢复会被整个跳过,它就永远停在普通标签状态
|
||||
// (图钉消失、出现关闭按钮);它又是 push 到数组末尾(普通标签之后),
|
||||
// 会把固定区拆散,顺序随「当前停在哪一页」随机变化。
|
||||
// PIN_VIEW 会把标签提升为固定标签并插到固定区末尾,于是按后端顺序依次恢复,
|
||||
// 固定区顺序恒等于库里的顺序。
|
||||
// 用 commit 而非 dispatch('pinView'):恢复过程绝不能触发落库。
|
||||
// 只取重建标签所需的字段,不要把路由定义上的 component/children 也塞进 visitedViews;
|
||||
// query/params 一律用空对象:固定标签来自右键菜单,天然不带参数,
|
||||
// 且 RouterVo.query 是菜单的默认参数 JSON 字符串,直接透传会被 vue-router 当成非法 query
|
||||
this.$store.dispatch('tagsView/addView', {
|
||||
this.$store.commit('tagsView/PIN_VIEW', {
|
||||
path: fullPath,
|
||||
fullPath: fullPath,
|
||||
name: route.name,
|
||||
@ -305,6 +316,11 @@ export default {
|
||||
meta: Object.assign({}, route.meta, { affix: true, pinned: true })
|
||||
})
|
||||
})
|
||||
}).catch(() => {
|
||||
// 拉取失败就不重建固定区。库里数据不受影响(写入本来就是单条增量的),
|
||||
// 用户重新固定一次即可;这里刻意不做任何清理动作,避免把好东西洗掉。
|
||||
// 必须给提示,否则用户会以为固定标签又丢了
|
||||
Message.warning('固定标签加载失败,刷新页面可重试')
|
||||
})
|
||||
},
|
||||
/**
|
||||
|
||||
@ -1,29 +1,12 @@
|
||||
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 存取,顺序即固定区顺序。
|
||||
// 本模块不再直接依赖 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)
|
||||
}
|
||||
// 固定项落库(sys_user_setting.pinned_tags),按 path 存、顺序即固定区顺序,
|
||||
// 同一账号在任何设备登录都能恢复固定区。
|
||||
// 写入走**服务端单条增量**:固定/取消固定各提交一个 path,前端不再提交自己那份列表,
|
||||
// 因此不存在「拿过期快照整份覆盖」的丢标签问题,多标签页/多端同时操作也不会互相覆盖。
|
||||
// 服务端在事务里「锁定读 → 改 → 写」,顺序由追加先后决定,与这里的固定区顺序一致。
|
||||
|
||||
// 固定区 = 排在普通标签之前的 affix 标签;新固定/取消固定的标签插到固定区末尾
|
||||
function moveToAffixBlockEnd(list, view) {
|
||||
@ -31,13 +14,43 @@ function moveToAffixBlockEnd(list, 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 = {
|
||||
visitedViews: [],
|
||||
cachedViews: [],
|
||||
iframeViews: [],
|
||||
// 用户右键「固定标签到任务栏」的标签,刷新/换设备后仍然存在
|
||||
// 初始为空,由 TagsView 在拿到后端数据后通过 SET_PINNED_VIEWS 填充
|
||||
pinnedViews: []
|
||||
iframeViews: []
|
||||
}
|
||||
|
||||
const mutations = {
|
||||
@ -103,27 +116,28 @@ const mutations = {
|
||||
state.cachedViews = []
|
||||
},
|
||||
UPDATE_VISITED_VIEW: (state, view) => {
|
||||
for (let v of state.visitedViews) {
|
||||
if (v.path === view.path) {
|
||||
v = Object.assign(v, view)
|
||||
for (const v of state.visitedViews) {
|
||||
if (v.path !== view.path) continue
|
||||
// 传入的 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
|
||||
}
|
||||
}
|
||||
},
|
||||
// 把标签置为「固定到任务栏」:已在标签栏的原地提升、不在的新建,统一插到固定区末尾。
|
||||
// 「用户右键固定」与「刷新后按后端列表恢复」共用它——两者的唯一差别是要不要落库,
|
||||
// 那由调用方区分:用户操作走 dispatch('pinView'),恢复直接 commit 本 mutation。
|
||||
PIN_VIEW: (state, view) => {
|
||||
const index = state.visitedViews.findIndex(v => v.path === view.path)
|
||||
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))
|
||||
}
|
||||
pinIntoAffixBlock(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)
|
||||
if (index === -1) return
|
||||
const target = state.visitedViews[index]
|
||||
@ -132,10 +146,6 @@ const mutations = {
|
||||
state.visitedViews.splice(index, 1)
|
||||
moveToAffixBlockEnd(state.visitedViews, target)
|
||||
},
|
||||
// 从后端载入固定标签,tagsView 初始化时调用
|
||||
SET_PINNED_VIEWS: (state, list) => {
|
||||
state.pinnedViews = Array.isArray(list) ? list : []
|
||||
},
|
||||
// 拖拽排序:把 visitedViews 里的 fromIndex 挪到 toIndex(toIndex 是「最终落在」的位置)
|
||||
SORT_VISITED_VIEWS: (state, { fromIndex, toIndex }) => {
|
||||
const list = state.visitedViews
|
||||
@ -203,43 +213,28 @@ const actions = {
|
||||
commit('ADD_VISITED_VIEW', view)
|
||||
},
|
||||
/**
|
||||
* 从后端载入该用户固定的任务栏标签
|
||||
* 固定到任务栏
|
||||
*
|
||||
* 落库后固定项不再依赖 localStorage,换浏览器/换电脑登录同一账号即可恢复。
|
||||
* 失败时保持空列表,不阻塞标签栏渲染。
|
||||
* 本地先变、再落库;落库失败就回滚本地状态并提示,避免「界面显示已固定、实际没存上」。
|
||||
* 提交的是**单个 path**——服务端按增量写入,不会碰到其它标签页/其它设备刚做的改动,
|
||||
* 因此这里不需要先拉一次全量列表,也就不用担心那份列表已经过期。
|
||||
*/
|
||||
loadPinnedViews({ commit }) {
|
||||
return getPinnedTags()
|
||||
.then(res => {
|
||||
const paths = Array.isArray(res.data) ? res.data : []
|
||||
// 只保留 path,完整 view 由 TagsView 用当前路由表补全
|
||||
commit('SET_PINNED_VIEWS', paths.map(p => ({ path: p })))
|
||||
return paths
|
||||
})
|
||||
.catch(() => {
|
||||
commit('SET_PINNED_VIEWS', [])
|
||||
return []
|
||||
pinView({ commit }, view) {
|
||||
commit('PIN_VIEW', view)
|
||||
return addPinnedTag(view.path).catch(() => {
|
||||
commit('UNPIN_VIEW', view)
|
||||
Message.warning('固定标签保存失败,请重试')
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 固定/取消固定后把最新 path 列表落库
|
||||
*
|
||||
* 失败时只提示不打断操作:内存中的标签区已经变了,
|
||||
* 用户不至于因为一次网络抖动失去反馈。
|
||||
* 取消固定
|
||||
*/
|
||||
persistPinnedViews({ state }) {
|
||||
return updatePinnedTags(toPinnedPaths(state.pinnedViews))
|
||||
.catch(() => {
|
||||
Message.warning('固定标签保存失败,下次登录可能不生效')
|
||||
})
|
||||
},
|
||||
pinView({ commit, dispatch, state }, view) {
|
||||
commit('PIN_VIEW', view)
|
||||
return dispatch('persistPinnedViews')
|
||||
},
|
||||
unpinView({ commit, dispatch, state }, view) {
|
||||
unpinView({ commit }, view) {
|
||||
commit('UNPIN_VIEW', view)
|
||||
return dispatch('persistPinnedViews')
|
||||
return removePinnedTag(view.path).catch(() => {
|
||||
commit('PIN_VIEW', view)
|
||||
Message.warning('取消固定保存失败,请重试')
|
||||
})
|
||||
},
|
||||
addCachedView({ commit }, 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-select>
|
||||
</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-checkbox v-model="historyDeviceFilter.showChild" @change="handleHistoryDeviceFilterChange">
|
||||
{{ $t('device.index.105953-18') }}
|
||||
@ -287,6 +295,7 @@
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
import { listDeviceShort, listThingsModel } from '@/api/iot/device.js';
|
||||
import BatchSerialNumberInput from '@/views/components/BatchSerialNumberInput';
|
||||
import { listGroup } from '@/api/iot/group';
|
||||
import { deptsTreeSelect } from '@/api/system/user';
|
||||
import { listGateway } from '@/api/iot/gateway.js';
|
||||
@ -306,6 +315,7 @@ import { blobValidate } from '@/utils/ruoyi';
|
||||
|
||||
export default {
|
||||
name: 'dataCenterHistory',
|
||||
components: { BatchSerialNumberInput },
|
||||
data() {
|
||||
return {
|
||||
activeTab: 'device',
|
||||
@ -322,6 +332,8 @@ export default {
|
||||
deptId: null,
|
||||
showChild: true,
|
||||
groupId: null,
|
||||
/** 英文逗号分隔的设备编号,支持一次填多个;为空则不过滤 */
|
||||
serialNumber: null,
|
||||
},
|
||||
deptOptions: [],
|
||||
myGroupList: [],
|
||||
@ -750,6 +762,9 @@ export default {
|
||||
if (this.historyDeviceFilter.groupId) {
|
||||
params.groupId = this.historyDeviceFilter.groupId;
|
||||
}
|
||||
if (this.historyDeviceFilter.serialNumber) {
|
||||
params.serialNumber = this.historyDeviceFilter.serialNumber;
|
||||
}
|
||||
listDeviceShort(params).then((res) => {
|
||||
if (res.code === 200) {
|
||||
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-form-item>
|
||||
<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 :label="$t('device.index.105953-4')" prop="status">
|
||||
<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 recycleRecord from './recycle-record';
|
||||
import allotRecord from './allot-record';
|
||||
import BatchSerialNumberInput from '@/views/components/BatchSerialNumberInput';
|
||||
|
||||
export default {
|
||||
name: 'Device',
|
||||
@ -417,6 +423,7 @@ export default {
|
||||
importRecord,
|
||||
recycleRecord,
|
||||
allotRecord,
|
||||
BatchSerialNumberInput,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@ -9,7 +9,12 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<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 label="设备类型" prop="sensorType">
|
||||
<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 { listGateway } from '@/api/iot/gateway'
|
||||
import { getDicts } from '@/api/system/dict/data'
|
||||
import BatchSerialNumberInput from '@/views/components/BatchSerialNumberInput'
|
||||
|
||||
const SENSOR_TYPE_DICT = 'platform_sensor_type'
|
||||
|
||||
export default {
|
||||
name: 'PlatformDeviceMapping',
|
||||
components: { BatchSerialNumberInput },
|
||||
data() {
|
||||
return {
|
||||
loading: false, submitLoading: false, showSearch: true,
|
||||
|
||||
@ -17,6 +17,10 @@
|
||||
</el-select>
|
||||
</setting-row>
|
||||
|
||||
<setting-row label="查询网关子设备" desc="按设备编号查询时生效:输入网关子设备编号会返回其所属网关设备;同时允许查到「网关子设备」类型的产品设备。不按编号查(默认列表)时不生效,列表始终不展示子设备。">
|
||||
<el-switch v-model="form.queryGatewayChild" />
|
||||
</setting-row>
|
||||
|
||||
<div class="setting-actions">
|
||||
<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>
|
||||
@ -32,10 +36,11 @@ import {
|
||||
} from '@/api/system/userSetting';
|
||||
import SettingRow from './SettingRow';
|
||||
|
||||
// 默认值与后端 UserSettingConstants.DEFAULT_LOGIN_REDIRECT 保持一致:
|
||||
// 未指定的账号保持原有的数据大屏落地行为
|
||||
// 默认值与后端 UserSettingConstants 保持一致:
|
||||
// 未指定的账号保持原有的数据大屏落地行为;子设备开关默认关闭
|
||||
const DEFAULT_FORM = {
|
||||
loginRedirect: '/bigscreen'
|
||||
loginRedirect: '/bigscreen',
|
||||
queryGatewayChild: false
|
||||
};
|
||||
|
||||
export default {
|
||||
@ -67,15 +72,22 @@ export default {
|
||||
getSetting() {
|
||||
getUserSetting().then((res) => {
|
||||
const data = res.data || {};
|
||||
// 后端对未配置过的用户返回默认值,此处仅在值缺失时兜底
|
||||
// 后端对未配置过的用户返回默认值,此处仅在值缺失时兜底。
|
||||
// queryGatewayChild 不能用 ||:布尔 false 会被吞掉,导致开关回显错乱。
|
||||
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() {
|
||||
this.saving = true;
|
||||
updateUserSetting({ loginRedirect: this.form.loginRedirect })
|
||||
updateUserSetting({
|
||||
loginRedirect: this.form.loginRedirect,
|
||||
queryGatewayChild: this.form.queryGatewayChild
|
||||
})
|
||||
.then(() => {
|
||||
this.$modal.msgSuccess('保存成功,下次登录生效');
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user