Merge branch 'dev/1.2_optimize' into dev/1.3_report2
This commit is contained in:
commit
2cb6237e37
@ -0,0 +1,123 @@
|
||||
package com.fastbee.web.controller.system;
|
||||
|
||||
import com.fastbee.common.annotation.Log;
|
||||
import com.fastbee.common.core.controller.BaseController;
|
||||
import com.fastbee.common.core.domain.AjaxResult;
|
||||
import com.fastbee.common.enums.BusinessType;
|
||||
import com.fastbee.common.utils.SecurityUtils;
|
||||
import com.fastbee.system.domain.LoginRedirectOption;
|
||||
import com.fastbee.system.domain.SysUserSetting;
|
||||
import com.fastbee.system.service.ISysUserSettingService;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 个人系统设置
|
||||
*
|
||||
* <p>本接口只处理「当前登录用户自己的」设置,无需额外的菜单权限点:
|
||||
* 能登录的用户天然有权读写自己的偏好。全局开关不在这里,
|
||||
* 仍走 SysConfigController 的 /system/config(权限 system:config:edit)。
|
||||
*
|
||||
* @author Sumuxi
|
||||
*/
|
||||
@Api(tags = "个人设置")
|
||||
@RestController
|
||||
@RequestMapping("/system/userSetting")
|
||||
public class SysUserSettingController extends BaseController {
|
||||
|
||||
@Autowired
|
||||
private ISysUserSettingService userSettingService;
|
||||
|
||||
@ApiOperation("获取当前用户的设置")
|
||||
@GetMapping()
|
||||
public AjaxResult get() {
|
||||
Long userId = SecurityUtils.getUserId();
|
||||
return AjaxResult.success(userSettingService.selectByUserId(userId));
|
||||
}
|
||||
|
||||
@ApiOperation("获取登录落地页候选项")
|
||||
@GetMapping("/loginRedirectOptions")
|
||||
public AjaxResult loginRedirectOptions() {
|
||||
return AjaxResult.success(userSettingService.listLoginRedirectOptions());
|
||||
}
|
||||
|
||||
@ApiOperation("获取当前用户固定的任务栏标签")
|
||||
@GetMapping("/pinnedTags")
|
||||
public AjaxResult pinnedTags() {
|
||||
return AjaxResult.success(userSettingService.getPinnedTags(SecurityUtils.getUserId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 固定标签按「单条增量」写入,不再提供整份列表的覆盖写
|
||||
*
|
||||
* <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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 部分更新:只提交、也只写「用户改动过的那几项」
|
||||
*
|
||||
* <p>刻意不是整体覆盖写(PUT):设置页有多个互相独立的配置项,
|
||||
* 整体覆盖会让「保存 A 配置」把 B 配置冲回默认值。
|
||||
* 这里按入参中非 null 的字段逐列更新,未传的字段在 SQL 层面就碰不到。
|
||||
* <p>用 PATCH 而非 PUT:PUT 的语义是整体替换,与部分更新名实不符。
|
||||
*/
|
||||
@ApiOperation("部分更新当前用户的设置")
|
||||
@Log(title = "个人设置", businessType = BusinessType.UPDATE)
|
||||
@PatchMapping()
|
||||
public AjaxResult update(@RequestBody SysUserSetting setting) {
|
||||
if (setting.getLoginRedirect() == null
|
||||
&& setting.getQueryGatewayChild() == null
|
||||
&& setting.getShowChild() == null) {
|
||||
return AjaxResult.error("没有需要保存的配置项");
|
||||
}
|
||||
// 只有在本次确实要改「登录后显示页面」时才做白名单校验;
|
||||
// 未传(null)表示本次不改它,不能顺手用默认值把它覆盖掉
|
||||
if (setting.getLoginRedirect() != null && !isValidLoginRedirect(setting.getLoginRedirect())) {
|
||||
return AjaxResult.error("登录后跳转页面不合法,请从系统菜单中选择");
|
||||
}
|
||||
// 审计字段由服务端填充,不接受前端传入,避免越权写入他人操作名
|
||||
userSettingService.updatePartial(SecurityUtils.getUserId(), setting, SecurityUtils.getUsername());
|
||||
return AjaxResult.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验跳转目标为系统内已注册的一级页签
|
||||
*
|
||||
* <p>该值最终进入 router.push。白名单取自 sys_menu(已剔除停用/隐藏/外链/二级嵌套页),
|
||||
* 比单纯校验「以 / 开头」更严:只能落到真实存在的菜单路由上,
|
||||
* 既挡住开放重定向,也避免指向被删除的菜单导致登录后 404。
|
||||
*/
|
||||
private boolean isValidLoginRedirect(String redirect) {
|
||||
for (LoginRedirectOption option : userSettingService.listLoginRedirectOptions()) {
|
||||
if (option.getPath().equals(redirect)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -31,6 +31,11 @@ public class CacheConstants {
|
||||
*/
|
||||
public static final String SYS_DICT_KEY = "sys_dict:";
|
||||
|
||||
/**
|
||||
* 用户系统设置 cache key(后接 userId)
|
||||
*/
|
||||
public static final String SYS_USER_SETTING_KEY = "sys_user_setting:";
|
||||
|
||||
/**
|
||||
* 数据库查询 cache key
|
||||
*/
|
||||
|
||||
@ -0,0 +1,106 @@
|
||||
package com.fastbee.common.constant;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户系统设置相关常量
|
||||
*
|
||||
* <p>维度判据:读取方能否拿到当前登录用户。
|
||||
* 拿得到(HTTP 请求,有 SecurityContext)→ 落 {@code sys_user_setting},per-user;
|
||||
* 拿不到(设备上电 {@code @Async} 线程)→ 落 {@code sys_config},全局。
|
||||
*
|
||||
* @author Sumuxi
|
||||
*/
|
||||
public class UserSettingConstants {
|
||||
|
||||
/**
|
||||
* 登录后默认跳转路由
|
||||
*
|
||||
* <p>取 /bigscreen:未在设置里指定页面的账号保持原有的数据大屏落地行为,
|
||||
* 不因引入本功能而改变既有用户的使用习惯。
|
||||
* 对应 yack-web router/index.js constantRoutes 中 hidden 的顶层大屏路由。
|
||||
*/
|
||||
public static final String DEFAULT_LOGIN_REDIRECT = "/bigscreen";
|
||||
|
||||
/**
|
||||
* 内置固定路由:不在 sys_menu 菜单表中,但可作为登录落地页
|
||||
*
|
||||
* <p>这两条由前端 constantRoutes 硬编码注册(菜单表里查不到):
|
||||
* /index —— 首页,Layout 的默认子路由
|
||||
* /bigscreen—— 数据大屏,hidden 菜单,任何角色都可访问
|
||||
* 候选项 = 内置路由 + sys_menu 一级页签,缺一则默认值 /index 会被白名单拒绝。
|
||||
*/
|
||||
public static final List<BuiltinRedirect> BUILTIN_REDIRECTS = Collections.unmodifiableList(Arrays.asList(
|
||||
new BuiltinRedirect("/index", "首页"),
|
||||
new BuiltinRedirect("/bigscreen", "数据大屏")
|
||||
));
|
||||
|
||||
/**
|
||||
* 是否下发读取设备 IMEI/ICCID 指令的全局配置 key
|
||||
*
|
||||
* <p>读取方为设备上电异步线程,无登录上下文,故存 {@code sys_config} 而非用户设置表。
|
||||
* 取代原实现中运维经 {@code BackendController#/set} 手动写入的裸 Redis key {@code get_imei_iccid}。
|
||||
*/
|
||||
public static final String READ_IMEI_ICCID_KEY = "iot.power_on.read_imei_iccid";
|
||||
|
||||
/**
|
||||
* 固定标签的「未固定」空值
|
||||
*
|
||||
* <p>列约束为 NOT NULL DEFAULT ('[]'),不存 NULL、不存空串。
|
||||
* 存空数组而非 NULL,读侧无需判空即可安全反序列化。
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
* 设备管理列表是否包含下级机构数据的默认值
|
||||
*
|
||||
* <p>语义对应 {@code Device.showChild}:仅在机构用户({@code deptId} 非空)时参与 SQL,
|
||||
* 开启时 {@code tenant_id} 落在「本机构及全部下级机构用户」集合内,关闭时仅本机构。
|
||||
* 原实现是设备管理页的勾选框 + 浏览器 localStorage(key {@code device-showChild}),
|
||||
* 换浏览器/换电脑就丢,现改为落库、由「系统管理 → 系统设置」页维护。
|
||||
* <p>默认 false = 与引入本开关前完全一致(默认列表不含下级机构数据)。
|
||||
*/
|
||||
public static final boolean DEFAULT_SHOW_CHILD = false;
|
||||
|
||||
private UserSettingConstants() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 内置固定路由项
|
||||
*/
|
||||
public static class BuiltinRedirect {
|
||||
private final String path;
|
||||
private final String name;
|
||||
|
||||
public BuiltinRedirect(String path, String name) {
|
||||
this.path = path;
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -810,6 +810,32 @@ public class RedisCache {
|
||||
return stringRedisTemplate.opsForValue().get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存基本的字符串
|
||||
*
|
||||
* 与 {@link #getStrCacheObject(String)} 配套。
|
||||
* 走 StringRedisTemplate 而非 redisTemplate,避免 FastJson2 把值包成带引号的 JSON 字符串,
|
||||
* 导致读回时多一层引号。
|
||||
*
|
||||
* @param key 缓存键值
|
||||
* @param value 缓存值,不能为 null
|
||||
*/
|
||||
public void setStrCacheObject(final String key, final String value) {
|
||||
stringRedisTemplate.opsForValue().set(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存基本的字符串并设置过期时间
|
||||
*
|
||||
* @param key 缓存键值
|
||||
* @param value 缓存值,不能为 null
|
||||
* @param timeout 时间
|
||||
* @param timeUnit 时间颗粒度
|
||||
*/
|
||||
public void setStrCacheObject(final String key, final String value, final Integer timeout, final TimeUnit timeUnit) {
|
||||
stringRedisTemplate.opsForValue().set(key, value, timeout, timeUnit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单个对象
|
||||
*
|
||||
|
||||
@ -3,9 +3,11 @@ package com.fastbee.iot.data.executor;
|
||||
import com.fastbee.base.service.ISessionStore;
|
||||
import com.fastbee.base.session.Session;
|
||||
import com.fastbee.common.constant.FastBeeConstant;
|
||||
import com.fastbee.common.constant.UserSettingConstants;
|
||||
import com.fastbee.common.core.mq.message.DeviceMessage;
|
||||
import com.fastbee.common.core.protocol.Message;
|
||||
import com.fastbee.common.core.redis.RedisKeyBuilder;
|
||||
import com.fastbee.common.core.text.Convert;
|
||||
import com.fastbee.common.utils.StringUtils;
|
||||
import com.fastbee.common.utils.gateway.protocol.ByteUtils;
|
||||
import com.fastbee.iot.data.service.IDeviceMessageService;
|
||||
@ -15,6 +17,7 @@ import com.fastbee.iot.domain.ModbusJob;
|
||||
import com.fastbee.iot.enums.DeviceType;
|
||||
import com.fastbee.iot.service.IDeviceService;
|
||||
import com.fastbee.iot.service.IModbusJobService;
|
||||
import com.fastbee.system.service.ISysConfigService;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
@ -68,6 +71,9 @@ public class PowerOnTriggerTaskExecutor {
|
||||
@Resource
|
||||
private IDeviceService deviceService;
|
||||
|
||||
@Resource
|
||||
private ISysConfigService sysConfigService;
|
||||
|
||||
/**
|
||||
* 每台设备同一时刻仅允许一个"上电触发轮询"流水线运行,避免重复触发/并发下发导致应答错配
|
||||
*/
|
||||
@ -90,8 +96,9 @@ public class PowerOnTriggerTaskExecutor {
|
||||
//log.debug("设置上电触发标识,设备:{}", serialNumber);
|
||||
|
||||
// 2、下发IMEI/ICCID读取指令
|
||||
if (redisTemplate.opsForValue().get("get_imei_iccid") == null){
|
||||
log.warn("无缓存标识,无法下发IMEI/ICCID指令");
|
||||
// 开关为全局配置:读该值的线程来自设备注册(@Async),无登录用户上下文,故不落用户设置表
|
||||
if (!isReadImeiIccidEnabled()) {
|
||||
log.info("IMEI/ICCID读取指令已关闭,跳过下发,设备编号:{}", serialNumber);
|
||||
return;
|
||||
}
|
||||
Device device = deviceService.selectDeviceInfoBySerialNumber(serialNumber);
|
||||
@ -131,6 +138,22 @@ public class PowerOnTriggerTaskExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取「是否下发IMEI/ICCID指令」全局开关
|
||||
*
|
||||
* <p>配置缺失时按开启处理:设备出厂时 imei/iccid/经纬度均为空,
|
||||
* 不下发则该字段永远无法填充,功能形同失效。
|
||||
*
|
||||
* @return true 下发,false 不下发
|
||||
*/
|
||||
private boolean isReadImeiIccidEnabled() {
|
||||
String configValue = sysConfigService.selectConfigByKey(UserSettingConstants.READ_IMEI_ICCID_KEY);
|
||||
if (StringUtils.isEmpty(configValue)) {
|
||||
return true;
|
||||
}
|
||||
return Convert.toBool(configValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并消费上电触发标识(原子操作)
|
||||
* @param serialNumber 设备编号
|
||||
|
||||
@ -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());
|
||||
@ -143,7 +159,7 @@ public class DeviceController extends BaseController {
|
||||
/**
|
||||
* 查询树形设备列表(用于报表生成等场景,网关设备可展开显示子设备)
|
||||
*/
|
||||
@PreAuthorize("@ss.hasAnyPermi('iot:device:list,iot:report:generate')")
|
||||
@PreAuthorize("@ss.hasAnyPermi('iot:device:list,iot:report:generate,dataCenter:repair:list')")
|
||||
@GetMapping("/treeList")
|
||||
@ApiOperation("查询树形设备列表(网关可展开子设备)")
|
||||
public AjaxResult treeList(Device device) {
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -1026,24 +1026,12 @@ public class DeviceServiceImpl implements IDeviceService {
|
||||
if (CollectionUtils.isEmpty(list)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
List<String> serialNumberList;
|
||||
SysUser user = getLoginUser().getUser();
|
||||
if (null != user.getDept()) {
|
||||
Long tenantId = user.getDept().getDeptUserId();
|
||||
serialNumberList = list.stream().filter(d -> tenantId.equals(d.getTenantId()))
|
||||
.map(DeviceShortOutput::getSerialNumber).collect(Collectors.toList());
|
||||
} else {
|
||||
serialNumberList = list.stream().map(DeviceShortOutput::getSerialNumber).collect(Collectors.toList());
|
||||
Integer entrance = null == device ? null : device.getEntrance();
|
||||
// 未处理告警数(alertCount)只有详情,根据产品查询设备详情 entrance=4
|
||||
if (Integer.valueOf(4).equals(entrance)) {
|
||||
fillAlertCount(list);
|
||||
}
|
||||
if (!CollectionUtils.isEmpty(serialNumberList)) {
|
||||
List<DeviceAlertCount> alist = alertLogService.selectDeviceAlertCount(serialNumberList);
|
||||
for (DeviceAlertCount item : alist) {
|
||||
list.stream()
|
||||
.filter(it -> Objects.equals(it.getSerialNumber(), item.getSerialNumber()))
|
||||
.forEach(it -> it.setAlertCount(item));
|
||||
}
|
||||
}
|
||||
fillShortListLatestReportTime(list, device.getEntrance());
|
||||
fillShortListLatestReportTime(list, entrance);
|
||||
long took = System.currentTimeMillis() - startTime;
|
||||
if (took > 1000L) {
|
||||
log.warn("selectDeviceShortList took: {}", took);
|
||||
@ -1051,6 +1039,39 @@ public class DeviceServiceImpl implements IDeviceService {
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 回填设备未处理告警数(DeviceShortOutput#alertCount)。
|
||||
* <p>
|
||||
* 只统计当前登录用户自己租户的设备:存在部门时按 dept.deptUserId 过滤,
|
||||
* 因此「查询下级机构」带出来的下级机构设备不会带告警徽标。
|
||||
*/
|
||||
private void fillAlertCount(List<DeviceShortOutput> list) {
|
||||
SysUser user = getLoginUser().getUser();
|
||||
Long deptUserId = null != user.getDept() ? user.getDept().getDeptUserId() : null;
|
||||
List<String> serialNumberList = list.stream()
|
||||
.filter(d -> null == deptUserId || deptUserId.equals(d.getTenantId()))
|
||||
.map(DeviceShortOutput::getSerialNumber)
|
||||
.collect(Collectors.toList());
|
||||
if (CollectionUtils.isEmpty(serialNumberList)) {
|
||||
return;
|
||||
}
|
||||
List<DeviceAlertCount> alertCountList = alertLogService.selectDeviceAlertCount(serialNumberList);
|
||||
if (CollectionUtils.isEmpty(alertCountList)) {
|
||||
return;
|
||||
}
|
||||
// 先按 serialNumber 建索引再回填,避免原先的嵌套 stream 匹配(O(设备数 × 告警行数))
|
||||
Map<String, DeviceAlertCount> alertCountMap = new HashMap<>(alertCountList.size());
|
||||
for (DeviceAlertCount item : alertCountList) {
|
||||
alertCountMap.put(item.getSerialNumber(), item);
|
||||
}
|
||||
for (DeviceShortOutput row : list) {
|
||||
DeviceAlertCount alertCount = alertCountMap.get(row.getSerialNumber());
|
||||
if (null != alertCount) {
|
||||
row.setAlertCount(alertCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户可见设备统计(与 shortList 相同范围)
|
||||
*/
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
package com.fastbee.system.domain;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 登录落地页候选项(系统菜单)
|
||||
*
|
||||
* <p>数据源为 {@code sys_menu} 中可作为落地页的页签菜单,供用户选择登录后进入的页面。
|
||||
* 已剔除按钮(F)、停用(status=1)、隐藏(visible=1) 与外链(is_frame=0)菜单——
|
||||
* 外链若进入本列表会成为开放重定向入口。
|
||||
*
|
||||
* @author Sumuxi
|
||||
*/
|
||||
public class LoginRedirectOption implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 完整路由 path,如 /iot/device
|
||||
*/
|
||||
private String path;
|
||||
|
||||
/**
|
||||
* 菜单名称
|
||||
*/
|
||||
private String menuName;
|
||||
|
||||
public LoginRedirectOption() {
|
||||
}
|
||||
|
||||
public LoginRedirectOption(String path, String menuName) {
|
||||
this.path = path;
|
||||
this.menuName = menuName;
|
||||
}
|
||||
|
||||
public String getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
public void setPath(String path) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
public String getMenuName() {
|
||||
return menuName;
|
||||
}
|
||||
|
||||
public void setMenuName(String menuName) {
|
||||
this.menuName = menuName;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,157 @@
|
||||
package com.fastbee.system.domain;
|
||||
|
||||
import com.fastbee.common.constant.UserSettingConstants;
|
||||
import com.fastbee.common.core.domain.BaseEntity;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import org.apache.commons.lang3.builder.ToStringBuilder;
|
||||
import org.apache.commons.lang3.builder.ToStringStyle;
|
||||
|
||||
/**
|
||||
* 用户系统设置对象 sys_user_setting
|
||||
*
|
||||
* <p>一行一用户(user_id 唯一)。所有设置列 NOT NULL + DEFAULT,
|
||||
* 不存在「未配置过」的 NULL 态;业务默认值见 {@link UserSettingConstants}。
|
||||
*
|
||||
* @author Sumuxi
|
||||
*/
|
||||
public class SysUserSetting extends BaseEntity {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 设置主键
|
||||
*/
|
||||
@ApiModelProperty(value = "设置主键")
|
||||
private Long settingId;
|
||||
|
||||
/**
|
||||
* 用户ID
|
||||
*/
|
||||
@ApiModelProperty(value = "用户ID")
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* 登录后跳转路由 path
|
||||
*/
|
||||
@ApiModelProperty(value = "登录后跳转路由path")
|
||||
private String loginRedirect;
|
||||
|
||||
/**
|
||||
* 任务栏固定标签,JSON 数组字符串,按固定顺序存放 route path
|
||||
*
|
||||
* <p>只存用户右键「固定标签到任务栏」的 pinned 集合;
|
||||
* 路由自身声明的 affix(如首页)由 constantRoutes 天然重建,不在此列。
|
||||
* 空数组字符串 "[]" 表示未固定,不存 NULL。
|
||||
*/
|
||||
@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;
|
||||
|
||||
/**
|
||||
* 设备管理列表是否包含下级机构数据
|
||||
*
|
||||
* <p>对应 {@code Device.showChild}:仅在机构用户(deptId 非空)时参与 SQL,
|
||||
* 开启时 {@code tenant_id} 落在「本机构及全部下级机构用户」集合内,关闭时仅本机构。
|
||||
* 原为设备管理页勾选框 + 浏览器 localStorage,现改为落库、由系统设置页维护;
|
||||
* 设备管理页只读取该值作为查询参数下发,页面内不再可就地修改。
|
||||
*/
|
||||
@ApiModelProperty(value = "设备管理列表是否包含下级机构数据")
|
||||
private Boolean showChild;
|
||||
|
||||
public SysUserSetting() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建一份带默认值的设置(用户首次保存时使用)
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 默认设置对象
|
||||
*/
|
||||
public static SysUserSetting defaultOf(Long userId) {
|
||||
SysUserSetting setting = new SysUserSetting();
|
||||
setting.setUserId(userId);
|
||||
setting.setLoginRedirect(UserSettingConstants.DEFAULT_LOGIN_REDIRECT);
|
||||
setting.setPinnedTags(UserSettingConstants.EMPTY_PINNED_TAGS);
|
||||
setting.setQueryGatewayChild(UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD);
|
||||
setting.setShowChild(UserSettingConstants.DEFAULT_SHOW_CHILD);
|
||||
return setting;
|
||||
}
|
||||
|
||||
public Long getSettingId() {
|
||||
return settingId;
|
||||
}
|
||||
|
||||
public void setSettingId(Long settingId) {
|
||||
this.settingId = settingId;
|
||||
}
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(Long userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public String getLoginRedirect() {
|
||||
return loginRedirect;
|
||||
}
|
||||
|
||||
public void setLoginRedirect(String loginRedirect) {
|
||||
this.loginRedirect = loginRedirect;
|
||||
}
|
||||
|
||||
public String getPinnedTags() {
|
||||
return pinnedTags;
|
||||
}
|
||||
|
||||
public void setPinnedTags(String pinnedTags) {
|
||||
this.pinnedTags = pinnedTags;
|
||||
}
|
||||
|
||||
public Boolean getQueryGatewayChild() {
|
||||
return queryGatewayChild;
|
||||
}
|
||||
|
||||
public void setQueryGatewayChild(Boolean queryGatewayChild) {
|
||||
this.queryGatewayChild = queryGatewayChild;
|
||||
}
|
||||
|
||||
public Boolean getShowChild() {
|
||||
return showChild;
|
||||
}
|
||||
|
||||
public void setShowChild(Boolean showChild) {
|
||||
this.showChild = showChild;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
|
||||
.append("settingId", getSettingId())
|
||||
.append("userId", getUserId())
|
||||
.append("loginRedirect", getLoginRedirect())
|
||||
.append("pinnedTags", getPinnedTags())
|
||||
.append("queryGatewayChild", getQueryGatewayChild())
|
||||
.append("showChild", getShowChild())
|
||||
.append("createBy", getCreateBy())
|
||||
.append("createTime", getCreateTime())
|
||||
.append("updateBy", getUpdateBy())
|
||||
.append("updateTime", getUpdateTime())
|
||||
.append("remark", getRemark())
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* 用户系统设置 数据层
|
||||
*
|
||||
* @author Sumuxi
|
||||
*/
|
||||
public interface SysUserSettingMapper {
|
||||
|
||||
/**
|
||||
* 按用户ID查询设置
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 设置,不存在返回 null
|
||||
*/
|
||||
SysUserSetting selectByUserId(Long userId);
|
||||
|
||||
/**
|
||||
* 部分更新设置:只更新入参中**非 null** 的列,未传的列保持库中原值
|
||||
*
|
||||
* <p>调用方先 {@link #insertDefaultIfAbsent} 保证行存在,否则 UPDATE 0 行会静默丢改动。
|
||||
* 刻意不提供「整体覆盖写」的入口:设置页每次只提交用户改动过的那几项,
|
||||
* 整体覆盖会让「保存 A 配置」把 B 配置冲回默认值。
|
||||
*
|
||||
* @param setting 设置,userId 必填,其余字段只填本次要改的
|
||||
* @return 影响行数
|
||||
*/
|
||||
int updateSetting(SysUserSetting setting);
|
||||
|
||||
/**
|
||||
* 仅更新固定标签列(整列覆盖)
|
||||
*
|
||||
* <p>只在 {@code addPinnedTag / removePinnedTag} 的「锁定读 → 改 → 写」事务内调用,
|
||||
* 入参是**锁内读出并改好的完整数组**。对外不再提供「全量覆盖写」的入口——
|
||||
* 那会让两个标签页各自拿旧快照互相覆盖,是丢固定标签的直接成因。
|
||||
*
|
||||
* @param setting 设置,userId 与 pinnedTags 必填
|
||||
* @return 影响行数
|
||||
*/
|
||||
int updatePinnedTags(SysUserSetting setting);
|
||||
|
||||
/**
|
||||
* 确保用户设置行存在:不存在则插入默认行,已存在则不做任何修改
|
||||
*
|
||||
* <p>固定标签的增量写入要在一行上做「锁定读 → 改 → 写」,行必须先存在。
|
||||
* 刻意不用 ON DUPLICATE 写其它列:那会覆盖
|
||||
* login_redirect / query_gateway_child / show_child,把用户已保存的设置冲回默认值。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param loginRedirect 新行使用的默认登录跳转
|
||||
* @param queryGatewayChild 新行使用的默认开关值
|
||||
* @param showChild 新行使用的默认「包含下级机构数据」值
|
||||
* @param operator 操作人用户名
|
||||
* @param now 当前时间
|
||||
* @return 影响行数(插入 1 / 已存在 0)
|
||||
*/
|
||||
int insertDefaultIfAbsent(@Param("userId") Long userId,
|
||||
@Param("loginRedirect") String loginRedirect,
|
||||
@Param("queryGatewayChild") Boolean queryGatewayChild,
|
||||
@Param("showChild") Boolean showChild,
|
||||
@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);
|
||||
|
||||
/**
|
||||
* 查询登录落地页候选项(可作为页签的菜单)
|
||||
*
|
||||
* @return 候选项列表
|
||||
*/
|
||||
List<LoginRedirectOption> selectLoginRedirectOptions();
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
package com.fastbee.system.service;
|
||||
|
||||
import com.fastbee.system.domain.LoginRedirectOption;
|
||||
import com.fastbee.system.domain.SysUserSetting;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户系统设置 服务层
|
||||
*
|
||||
* <p>所有方法的 userId 均由调用方显式传入,Service 内部不读 SecurityUtils。
|
||||
* 原因:设置读取方不只有 HTTP 请求线程(可拿到 SecurityContext),
|
||||
* 还有设备上电等无登录上下文的线程,不能假设一定存在登录态。
|
||||
*
|
||||
* @author Sumuxi
|
||||
*/
|
||||
public interface ISysUserSettingService {
|
||||
|
||||
/**
|
||||
* 查询用户设置;未保存过时返回带默认值的对象(不会返回 null)
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 用户设置
|
||||
*/
|
||||
SysUserSetting selectByUserId(Long userId);
|
||||
|
||||
/**
|
||||
* 部分更新用户设置:只写 {@code patch} 中非 null 的字段,未传的字段保持库中原值
|
||||
*
|
||||
* <p>调用方每次只提交「用户真正改动过」的配置项,因此保存某一项不会把其它项冲成默认值。
|
||||
* 用户还没有设置行时先落一行默认值再更新,避免 UPDATE 0 行静默丢改动。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param patch 只填本次要改的字段,userId 字段忽略,以入参 userId 为准
|
||||
* @param operator 操作人用户名,写入 update_by
|
||||
* @return 影响行数
|
||||
*/
|
||||
int updatePartial(Long userId, SysUserSetting patch, String operator);
|
||||
|
||||
/**
|
||||
* 查询登录落地页候选项
|
||||
*
|
||||
* @return 候选项列表
|
||||
*/
|
||||
List<LoginRedirectOption> listLoginRedirectOptions();
|
||||
|
||||
/**
|
||||
* 固定一个任务栏标签
|
||||
*
|
||||
* <p>增量写入:已固定则原样返回(幂等,**不改动它的位置**),未固定则追加到固定区末尾。
|
||||
* 并发安全由服务端「锁定读 → 改 → 写」保证——多个标签页/多端同时固定不会互相覆盖,
|
||||
* 因为前端不再提交自己那份可能过期的完整列表。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param path 要固定的路由 path
|
||||
* @param operator 操作人用户名,写入 update_by
|
||||
*/
|
||||
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);
|
||||
|
||||
/**
|
||||
* 查询任务栏固定标签
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 固定标签的路由 path 列表,按固定顺序;未固定时返回空列表
|
||||
*/
|
||||
List<String> getPinnedTags(Long userId);
|
||||
}
|
||||
@ -0,0 +1,268 @@
|
||||
package com.fastbee.system.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.baomidou.dynamic.datasource.annotation.DS;
|
||||
import com.fastbee.common.constant.CacheConstants;
|
||||
import com.fastbee.common.constant.UserSettingConstants;
|
||||
import com.fastbee.common.core.redis.RedisCache;
|
||||
import com.fastbee.common.utils.StringUtils;
|
||||
import com.fastbee.system.domain.LoginRedirectOption;
|
||||
import com.fastbee.system.domain.SysUserSetting;
|
||||
import com.fastbee.system.mapper.SysUserSettingMapper;
|
||||
import com.fastbee.system.service.ISysUserSettingService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户系统设置 服务层实现
|
||||
*
|
||||
* <p>读取策略:Redis 缓存未命中则查库并回填,未配置过则返回默认值对象。
|
||||
* 缓存值为空表示「该用户还没有记录」,此时不写入缓存——
|
||||
* 否则用户首次保存前,每次读都会穿透到数据库。
|
||||
*
|
||||
* @author Sumuxi
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class SysUserSettingServiceImpl implements ISysUserSettingService {
|
||||
|
||||
/**
|
||||
* 缓存值的字段分隔符
|
||||
*
|
||||
* <p>用 \n 而不是常见的逗号/竖线:loginRedirect 是路由 path 不含换行,
|
||||
* pinnedTags 是 JSON 数组(元素为带引号的字符串)也不含裸换行,
|
||||
* showChild 与 queryGatewayChild 是布尔字面量,四段互不冲突。
|
||||
* <p>字段顺序固定为:loginRedirect | pinnedTags | showChild | queryGatewayChild,
|
||||
* 与 {@link #parseCached} 一一对应,增删字段必须同步改这两处。
|
||||
*/
|
||||
private static final String CACHE_SEPARATOR = "\n";
|
||||
|
||||
@Autowired
|
||||
private SysUserSettingMapper userSettingMapper;
|
||||
|
||||
@Autowired
|
||||
private RedisCache redisCache;
|
||||
|
||||
@Override
|
||||
@DS("master")
|
||||
public SysUserSetting selectByUserId(Long userId) {
|
||||
if (userId == null) {
|
||||
return SysUserSetting.defaultOf(null);
|
||||
}
|
||||
String cacheKey = getCacheKey(userId);
|
||||
// 缓存的是扁平字符串而非整个 POJO,理由同 SysConfigServiceImpl#selectConfigByKey:
|
||||
// 本项目 Redis 序列化器是 FastJson2 + SupportAutoType,直接缓存继承 BaseEntity
|
||||
// (含 Map<String,Object> params 等字段)的对象,反序列化存在取值不稳与
|
||||
// autoType 风险;缓存扁平字段可完全规避。
|
||||
String cached = redisCache.getStrCacheObject(cacheKey);
|
||||
if (StringUtils.isNotEmpty(cached)) {
|
||||
SysUserSetting fromCache = parseCached(cached, userId);
|
||||
if (fromCache != null) {
|
||||
return fromCache;
|
||||
}
|
||||
}
|
||||
SysUserSetting setting = userSettingMapper.selectByUserId(userId);
|
||||
if (setting == null) {
|
||||
return SysUserSetting.defaultOf(userId);
|
||||
}
|
||||
writeCache(cacheKey, setting);
|
||||
return setting;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把设置刷入缓存
|
||||
*/
|
||||
private void writeCache(String cacheKey, SysUserSetting setting) {
|
||||
redisCache.setStrCacheObject(cacheKey,
|
||||
setting.getLoginRedirect() + CACHE_SEPARATOR
|
||||
+ setting.getPinnedTags() + CACHE_SEPARATOR
|
||||
+ setting.getShowChild() + CACHE_SEPARATOR
|
||||
+ setting.getQueryGatewayChild());
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析缓存值,格式非法时返回 null 走查库
|
||||
*
|
||||
* <p>段数必须严格等于 4:历史版本只缓存了 2 段(loginRedirect + pinnedTags)、
|
||||
* 后又扩到 3 段(追加 queryGatewayChild)。若按缺省值补齐,会把旧格式里
|
||||
* 那个位置的字段当成 showChild 读出来,故段数不等一律判为未命中,由查库回填新格式。
|
||||
* 缓存无 TTL,但每个用户最多回填一次。
|
||||
*
|
||||
* @param cached 缓存字符串
|
||||
* @param userId 用户ID
|
||||
* @return 设置对象,解析失败返回 null
|
||||
*/
|
||||
private SysUserSetting parseCached(String cached, Long userId) {
|
||||
String[] parts = cached.split(CACHE_SEPARATOR, -1);
|
||||
if (parts.length != 4) {
|
||||
return null;
|
||||
}
|
||||
SysUserSetting setting = SysUserSetting.defaultOf(userId);
|
||||
setting.setLoginRedirect(parts[0]);
|
||||
setting.setPinnedTags(parts[1]);
|
||||
setting.setShowChild(Boolean.parseBoolean(parts[2]));
|
||||
setting.setQueryGatewayChild(Boolean.parseBoolean(parts[3]));
|
||||
return setting;
|
||||
}
|
||||
|
||||
@Override
|
||||
@DS("master")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int updatePartial(Long userId, SysUserSetting patch, String operator) {
|
||||
Date now = new Date();
|
||||
// 行必须先存在,否则后面的部分更新会 UPDATE 0 行、静默丢掉本次改动
|
||||
userSettingMapper.insertDefaultIfAbsent(userId, UserSettingConstants.DEFAULT_LOGIN_REDIRECT,
|
||||
UserSettingConstants.DEFAULT_QUERY_GATEWAY_CHILD, UserSettingConstants.DEFAULT_SHOW_CHILD,
|
||||
operator, now);
|
||||
patch.setUserId(userId);
|
||||
patch.setUpdateBy(operator);
|
||||
patch.setUpdateTime(now);
|
||||
int rows = userSettingMapper.updateSetting(patch);
|
||||
// 写库成功后重查库再回填缓存:patch 里只有本次改动的字段,
|
||||
// 直接拿它刷缓存会把未改动字段写成 null。
|
||||
refreshCache(userId, patch);
|
||||
return rows;
|
||||
}
|
||||
|
||||
@Override
|
||||
@DS("master")
|
||||
@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, UserSettingConstants.DEFAULT_SHOW_CHILD,
|
||||
operator, now);
|
||||
List<String> paths = parsePinnedTags(userSettingMapper.selectPinnedTagsForUpdate(userId));
|
||||
if (paths.contains(path)) {
|
||||
// 已固定:幂等返回。不重复追加,也不把它挪到末尾,避免重复点击改变固定区顺序
|
||||
return;
|
||||
}
|
||||
paths.add(path);
|
||||
writePinnedTags(userId, paths, operator, now);
|
||||
}
|
||||
|
||||
@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.toJSONString(paths));
|
||||
patch.setUpdateBy(operator);
|
||||
patch.setUpdateTime(now);
|
||||
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 ? fallback : latest);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getPinnedTags(Long userId) {
|
||||
return parsePinnedTags(selectByUserId(userId).getPinnedTags());
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验固定标签 path 是否合法
|
||||
*
|
||||
* <p>只接受站内绝对路径,且排除协议相对地址({@code //host})、
|
||||
* 带协议头的地址与路径穿越。path 最终会进 router.push,混入外链会变成开放重定向入口。
|
||||
*
|
||||
* @param path 待校验的路由 path
|
||||
* @return 合法返回 true
|
||||
*/
|
||||
private boolean isValidPinnedPath(String path) {
|
||||
if (StringUtils.isEmpty(path) || !path.startsWith("/")) {
|
||||
return false;
|
||||
}
|
||||
return !path.startsWith("//") && !path.contains("://") && !path.contains("..");
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析固定标签,脏数据降级为空列表
|
||||
*
|
||||
* <p>列是 TEXT,允许人工改过;解析失败不应让整个设置页报错。
|
||||
*
|
||||
* @param json JSON 数组字符串
|
||||
* @return path 列表
|
||||
*/
|
||||
private List<String> parsePinnedTags(String json) {
|
||||
if (StringUtils.isEmpty(json)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
try {
|
||||
List<String> list = JSON.parseArray(json, String.class);
|
||||
return list == null ? new ArrayList<>() : list;
|
||||
} catch (Exception e) {
|
||||
log.warn("解析 pinned_tags 失败,降级为空列表,原始值:{}", json);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@DS("master")
|
||||
public List<LoginRedirectOption> listLoginRedirectOptions() {
|
||||
List<LoginRedirectOption> options = new ArrayList<>();
|
||||
// 内置固定路由优先:/index 与 /bigscreen 由前端 constantRoutes 注册,不在 sys_menu 中
|
||||
for (UserSettingConstants.BuiltinRedirect builtin : UserSettingConstants.BUILTIN_REDIRECTS) {
|
||||
options.add(new LoginRedirectOption(builtin.getPath(), builtin.getName()));
|
||||
}
|
||||
List<LoginRedirectOption> menuOptions = userSettingMapper.selectLoginRedirectOptions();
|
||||
if (menuOptions != null && !menuOptions.isEmpty()) {
|
||||
for (LoginRedirectOption option : menuOptions) {
|
||||
// 菜单若与内置路由同名路径,以后者为准并去重,避免下拉出现重复项
|
||||
boolean duplicated = options.stream().anyMatch(o -> o.getPath().equals(option.getPath()));
|
||||
if (!duplicated) {
|
||||
options.add(option);
|
||||
}
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private String getCacheKey(Long userId) {
|
||||
return CacheConstants.SYS_USER_SETTING_KEY + userId;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,131 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.fastbee.system.mapper.SysUserSettingMapper">
|
||||
|
||||
<resultMap type="com.fastbee.system.domain.SysUserSetting" id="SysUserSettingResult">
|
||||
<result property="settingId" column="setting_id" />
|
||||
<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="showChild" column="show_child" />
|
||||
<result property="createBy" column="create_by" />
|
||||
<result property="createTime" column="create_time" />
|
||||
<result property="updateBy" column="update_by" />
|
||||
<result property="updateTime" column="update_time" />
|
||||
<result property="remark" column="remark" />
|
||||
</resultMap>
|
||||
|
||||
<resultMap type="com.fastbee.system.domain.LoginRedirectOption" id="LoginRedirectOptionResult">
|
||||
<result property="path" column="path" />
|
||||
<result property="menuName" column="menu_name" />
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectUserSettingVo">
|
||||
select setting_id, user_id, login_redirect, pinned_tags, query_gateway_child, show_child,
|
||||
create_by, create_time, update_by, update_time, remark
|
||||
from sys_user_setting
|
||||
</sql>
|
||||
|
||||
<select id="selectByUserId" parameterType="Long" resultMap="SysUserSettingResult">
|
||||
<include refid="selectUserSettingVo"/>
|
||||
where user_id = #{userId}
|
||||
</select>
|
||||
|
||||
<!--
|
||||
部分更新:只更新**显式传入**(非 null)的列。
|
||||
设置页每次只提交用户真正改动过的那几项,因此保存 A 配置不可能把 B 配置冲成默认值——
|
||||
这是把写入从「整体覆盖」拆成「单量提交」的关键:
|
||||
· Boolean 为 null = 本次不改这一项(显式传 false 与不传是两回事,包装类型能区分)
|
||||
· String 为 null = 本次不改这一项
|
||||
刻意不碰 pinned_tags:它由 tags-view 的独立写入路径维护,混进来会互相覆盖。
|
||||
调用方必须先 insertDefaultIfAbsent 保证行存在,否则 UPDATE 0 行会静默丢改动。
|
||||
-->
|
||||
<update id="updateSetting" parameterType="com.fastbee.system.domain.SysUserSetting">
|
||||
update sys_user_setting
|
||||
<set>
|
||||
<if test="loginRedirect != null">login_redirect = #{loginRedirect},</if>
|
||||
<if test="queryGatewayChild != null">query_gateway_child = #{queryGatewayChild},</if>
|
||||
<if test="showChild != null">show_child = #{showChild},</if>
|
||||
update_by = #{updateBy},
|
||||
update_time = #{updateTime}
|
||||
</set>
|
||||
where user_id = #{userId}
|
||||
</update>
|
||||
|
||||
<!--
|
||||
确保设置行存在:不存在则插入默认行,已存在则**什么都不改**。
|
||||
两处调用:① 固定标签的增量写入要在一行上做「锁定读 → 改 → 写」,行必须先存在;
|
||||
② 设置页的部分更新前先兜一行,否则 UPDATE 0 行会静默丢改动。
|
||||
刻意不用 ON DUPLICATE 写其它列:那会覆盖 login_redirect / query_gateway_child / show_child,
|
||||
把用户已保存的设置冲回默认值;这里用 user_id = user_id 作为 no-op 更新。
|
||||
-->
|
||||
<insert id="insertDefaultIfAbsent">
|
||||
insert into sys_user_setting
|
||||
(user_id, login_redirect, pinned_tags, query_gateway_child, show_child, create_by, create_time, update_by, update_time)
|
||||
values
|
||||
(#{userId}, #{loginRedirect}, '[]', #{queryGatewayChild}, #{showChild}, #{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>
|
||||
|
||||
<!--
|
||||
整列覆盖写固定标签。与设置页的部分更新是两条独立写入路径:
|
||||
若把 pinned_tags 并进 updateSetting,保存登录跳转等配置时会把固定标签一并覆盖掉。
|
||||
**只在 addPinnedTag / removePinnedTag 的事务内调用**,且在 SELECT ... FOR UPDATE
|
||||
之后落下,入参是锁内读出并改好的完整数组;对外不再有全量覆盖写的入口。
|
||||
-->
|
||||
<update id="updatePinnedTags" parameterType="com.fastbee.system.domain.SysUserSetting">
|
||||
update sys_user_setting
|
||||
set pinned_tags = #{pinnedTags},
|
||||
update_by = #{updateBy},
|
||||
update_time = #{updateTime}
|
||||
where user_id = #{userId}
|
||||
</update>
|
||||
|
||||
<!--
|
||||
登录落地页候选项:取可作为页签的菜单,拼出完整路由 path。
|
||||
过滤条件缺一不可:
|
||||
menu_type='C' 只要页签,排除按钮(F)与目录(M)
|
||||
status='0' 排除停用
|
||||
visible='0' 排除菜单树里隐藏的
|
||||
is_frame=1 排除外链(is_frame=0,component 为 http 地址),防止开放重定向
|
||||
component 非空 路由必须有对应组件
|
||||
p.menu_type='M' 只取一级页签。父级是页签(C)的是二级嵌套操作页
|
||||
(如设备管理下的「设备分享列表」),不适合作为登录落地页。
|
||||
路径拼接:父菜单 path 为空(顶级菜单 parent_id=0)时用 IFNULL 兜住,
|
||||
再用 TRIM(TRAILING '/') 清掉尾部斜杠,避免 CONCAT 产出 '//system' 或 '/xxx/'。
|
||||
排序按父目录 order_num 再按自身 order_num,与左侧菜单树顺序一致。
|
||||
-->
|
||||
<select id="selectLoginRedirectOptions" resultMap="LoginRedirectOptionResult">
|
||||
select concat('/', trim(trailing '/' from concat(ifnull(p.path, ''), '/', m.path))) as path,
|
||||
m.menu_name
|
||||
from sys_menu m
|
||||
left join sys_menu p on m.parent_id = p.menu_id
|
||||
where m.menu_type = 'C'
|
||||
and m.status = '0'
|
||||
and m.visible = '0'
|
||||
and m.is_frame = '1'
|
||||
and m.component is not null
|
||||
and m.component != ''
|
||||
and m.path is not null
|
||||
and m.path != ''
|
||||
and p.menu_type = 'M'
|
||||
order by p.order_num, m.order_num
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
116
sql/sys_user_setting.sql
Normal file
116
sql/sys_user_setting.sql
Normal file
@ -0,0 +1,116 @@
|
||||
-- =============================================
|
||||
-- 用户系统设置(用户维度)+ 设备侧全局开关入库
|
||||
-- 维度判据:读的时候拿不拿得到 userId
|
||||
-- · 拿得到(HTTP 请求,有 SecurityContext)→ sys_user_setting,per-user
|
||||
-- · 拿不到(设备上电 @Async 线程)→ sys_config,全局
|
||||
-- 配套代码:SysUserSetting* / PowerOnTriggerTaskExecutor:93
|
||||
-- 版本: 1.0 2026-10-05
|
||||
-- =============================================
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 一、用户维度设置表
|
||||
-- 一行一用户;所有设置列 NOT NULL + DEFAULT,不设「未配置过」这种 NULL 态
|
||||
-- 业务默认值由 Java 常量兜底(UserSettingConstants),不依赖 DB 兜底
|
||||
--
|
||||
-- pinned_tags:任务栏「固定标签到任务栏」的 JSON 数组,按固定顺序存 route path。
|
||||
-- * 原实现存浏览器 localStorage(key: pinned-tags),换浏览器/换电脑就丢,
|
||||
-- 现改为落库,同一账号在任何设备登录都能恢复固定区。
|
||||
-- * 只存用户右键固定的 pinned 集合;路由自身声明的 affix(首页,router/index.js:80)
|
||||
-- 由 constantRoutes 天然重建,不入此列。
|
||||
-- * **写入语义:单条增量**。接口是 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 '[]'。
|
||||
--
|
||||
-- 已建过表的库用下面的 ALTER 补列(可重复执行前请先确认列不存在):
|
||||
-- ALTER TABLE `sys_user_setting`
|
||||
-- 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`;
|
||||
-- ALTER TABLE `sys_user_setting`
|
||||
-- ADD COLUMN `show_child` tinyint(1) NOT NULL DEFAULT 0
|
||||
-- COMMENT '设备管理列表是否包含下级机构数据(0否1是)'
|
||||
-- AFTER `query_gateway_child`;
|
||||
--
|
||||
-- query_gateway_child:开启后 /iot/device/shortList **按设备编号查询时**(serialNumber 非空):
|
||||
-- ① 命中的若是网关子设备编号,返回其所属的网关设备(子设备 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 设置。
|
||||
--
|
||||
-- show_child:设备管理列表是否包含下级机构数据(原为设备管理页勾选框,值存在浏览器
|
||||
-- localStorage 的 device-showChild,换浏览器/换电脑就丢,现改为落库)。
|
||||
-- 生效条件同 Device.showChild:仅在 deptId 非空(机构用户)时参与 SQL,语义见
|
||||
-- DeviceMapper.xml#selectDeviceShortListJoinWhere —— 开启时 tenant_id 落在「本机构
|
||||
-- 及全部下级机构用户」集合内,关闭时仅本机构。
|
||||
-- 该值现在由「系统管理 → 系统设置」页维护,设备管理页只读不写(页面内的勾选框已移除)。
|
||||
-- 设备管理页进入时读一次该设置并作为查询参数下发,故本列是「前端读取、后端不感知」的偏好,
|
||||
-- DeviceController 在未收到 showChild 时依然按 false 处理。
|
||||
-- 默认 0 = 与引入本开关前完全一致。
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `sys_user_setting` (
|
||||
`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是)',
|
||||
`show_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 '更新者',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
|
||||
PRIMARY KEY (`setting_id`) USING BTREE,
|
||||
UNIQUE KEY `uk_sys_user_setting_user` (`user_id`) USING BTREE COMMENT '每用户仅一行'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||||
ROW_FORMAT=DYNAMIC COMMENT='用户系统设置表';
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 二、设备侧全局开关 → 复用已有 sys_config 表
|
||||
-- 原实现:PowerOnTriggerTaskExecutor 直读运维手动写入的裸 Redis key
|
||||
-- "get_imei_iccid"(经 BackendController /set 写入)
|
||||
-- 新实现:改读 sys_config,运维改用已有的 PUT /system/config
|
||||
-- config_type='Y' = 系统内置,deleteConfigByIds 会拒删
|
||||
-- ------------------------------------------------------------
|
||||
INSERT INTO `sys_config`
|
||||
(`config_name`, `config_key`, `config_value`, `config_type`, `create_by`, `create_time`, `remark`)
|
||||
SELECT '是否下发读取设备IMEI/ICCID指令', 'iot.power_on.read_imei_iccid', 'true', 'Y', 'system', NOW(),
|
||||
'设备上电后是否下发 AT+IMEI/AT+ICCID/AT+LBS 指令读取;读该值的线程无登录上下文,故为全局配置'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `sys_config` WHERE `config_key` = 'iot.power_on.read_imei_iccid'
|
||||
);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 三、菜单:系统管理 → 个人设置
|
||||
-- 不设 system:setting:list 权限点的原因:
|
||||
-- 接口读写的是「当前登录用户自己的」设置,无需菜单级授权;
|
||||
-- 若填了权限点,非管理员账号将看不到也用不了自己的设置页。
|
||||
-- menu_type='C' 菜单,component 指向新建的 views/system/setting/index.vue
|
||||
-- ------------------------------------------------------------
|
||||
INSERT INTO `sys_menu`
|
||||
(`menu_name`, `parent_id`, `order_num`, `path`, `component`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`)
|
||||
SELECT '系统设置', 1, 13, 'setting', 'system/setting/index', 1, 0, 'C', '0', '0', '', 'setting', 'system', NOW()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `sys_menu` WHERE `parent_id` = 1 AND `path` = 'setting'
|
||||
);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 四、清理历史遗留的裸 Redis key
|
||||
-- 原开关 key 无前缀、不在任何 key 生成器中,与 RedisKeyBuilder 规范不符。
|
||||
-- 确认线上设备已完成一轮上电、开关已由 sys_config 接管后再执行:
|
||||
-- redis-cli DEL get_imei_iccid
|
||||
-- 切勿在切换完成前执行,否则 IMEI/ICCID 指令会立即停止下发。
|
||||
-- ------------------------------------------------------------
|
||||
56
yack-web/src/api/system/userSetting.js
Normal file
56
yack-web/src/api/system/userSetting.js
Normal file
@ -0,0 +1,56 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取当前用户的设置
|
||||
export function getUserSetting() {
|
||||
return request({
|
||||
url: '/system/userSetting',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 部分更新当前用户的设置:data 里只放**本次改动过**的字段,未改动的一律不放
|
||||
// 用 PATCH 而非 PUT:服务端只按非空字段逐列更新,未传的字段保持库中原值,
|
||||
// 这样保存某一项配置不会把其它配置冲回默认值
|
||||
export function updateUserSetting(data) {
|
||||
return request({
|
||||
url: '/system/userSetting',
|
||||
method: 'patch',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 获取登录落地页候选项(取自系统菜单)
|
||||
export function listLoginRedirectOptions() {
|
||||
return request({
|
||||
url: '/system/userSetting/loginRedirectOptions',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取当前用户固定的任务栏标签
|
||||
export function getPinnedTags() {
|
||||
return request({
|
||||
url: '/system/userSetting/pinnedTags',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 固定一个任务栏标签(服务端幂等追加到固定区末尾)
|
||||
// 刻意不做「整份列表覆盖写」:前端持有的列表可能已被别的标签页改过,
|
||||
// 整份提交会把对方的改动静默抹掉
|
||||
export function addPinnedTag(path) {
|
||||
return request({
|
||||
url: '/system/userSetting/pinnedTags',
|
||||
method: 'post',
|
||||
params: { path }
|
||||
})
|
||||
}
|
||||
|
||||
// 取消固定一个任务栏标签(服务端幂等移除)
|
||||
export function removePinnedTag(path) {
|
||||
return request({
|
||||
url: '/system/userSetting/pinnedTags',
|
||||
method: 'delete',
|
||||
params: { path }
|
||||
})
|
||||
}
|
||||
@ -426,11 +426,6 @@
|
||||
"product.product-modbus.562372-44": "Add a single data register",
|
||||
"product.product-modbus.562372-45": "Add multiple data registers",
|
||||
"product.product-modbus.562372-46": "The default address cannot be empty",
|
||||
"product.product-modbus.562372-47": "Register address cannot be empty",
|
||||
"product.product-modbus.562372-48": "Read only (0- No, 1- Yes) cannot be empty",
|
||||
"product.product-modbus.562372-49": "Modbus data type cannot be empty",
|
||||
"product.product-modbus.562372-50": "The number of reads cannot be empty",
|
||||
"product.product-modbus.562372-51": "Register type 1-IO register 2-data register cannot be empty",
|
||||
"product.product-modbus.562372-52": "Save successful",
|
||||
"product.product-modbus.562372-53": "Modified successfully",
|
||||
"product.product-modbus.562372-311": "Positional offset",
|
||||
@ -551,5 +546,21 @@
|
||||
"product.product-things-model.142341-200": "Click symbols to build the formula",
|
||||
"product.product-things-model.142341-201": "Reference product model identifiers",
|
||||
"product.product-things-model.142341-202": "No available identifiers",
|
||||
"product.product-things-model.142341-203": "Add Calculated Model"
|
||||
"product.product-things-model.142341-203": "Add Calculated Model",
|
||||
"product.product-modbus.562372-320": "Batch",
|
||||
"product.product-modbus.562372-321": "Scope: all {n} rows on this page",
|
||||
"product.product-modbus.562372-323": "{n} rows updated",
|
||||
"product.product-modbus.562372-325": "skipped {m} rows (data type mismatch)",
|
||||
"product.product-modbus.562372-326": "Batch delete",
|
||||
"product.product-modbus.562372-327": "Select rows to delete first",
|
||||
"product.product-modbus.562372-328": "The {n} selected rows ({m} already saved) will be removed locally and take effect after you click Save. Continue?",
|
||||
"product.product-modbus.562372-329": "1 (16-bit)",
|
||||
"product.product-modbus.562372-330": "2 (32-bit)",
|
||||
"product.product-modbus.562372-331": "Click \"Edit registers\" to enter edit mode first",
|
||||
"product.product-modbus.562372-333": "Scope: {n} selected rows",
|
||||
"product.product-modbus.562372-334": "Advances by read count · Enter or blur to generate",
|
||||
"product.product-modbus.562372-335": "Generate",
|
||||
"product.product-modbus.562372-336": "Generated {n} addresses: {from} -> {to}",
|
||||
"product.product-modbus.562372-337": "{m} duplicate slave + register address pairs on this page",
|
||||
"product.product-modbus.562372-338": "Product is published; click \"Unpublish\" first to edit registers"
|
||||
}
|
||||
|
||||
@ -426,11 +426,6 @@
|
||||
"product.product-modbus.562372-44": "添加单个数据寄存器",
|
||||
"product.product-modbus.562372-45": "添加多个数据寄存器",
|
||||
"product.product-modbus.562372-46": "默认地址不能为空",
|
||||
"product.product-modbus.562372-47": "寄存器地址不能为空",
|
||||
"product.product-modbus.562372-48": "是否只读(0-否,1-是)不能为空",
|
||||
"product.product-modbus.562372-49": "modbus数据类型不能为空",
|
||||
"product.product-modbus.562372-50": "读取个数不能为空",
|
||||
"product.product-modbus.562372-51": "寄存器类型 1-IO寄存器 2-数据寄存器不能为空",
|
||||
"product.product-modbus.562372-52": "保存成功",
|
||||
"product.product-modbus.562372-53": "修改成功",
|
||||
"product.product-modbus.562372-311": "位偏移量",
|
||||
@ -551,5 +546,21 @@
|
||||
"product.product-things-model.142341-200": "点击符号快速拼接",
|
||||
"product.product-things-model.142341-201": "引用产品物模型标识",
|
||||
"product.product-things-model.142341-202": "暂无可用标识",
|
||||
"product.product-things-model.142341-203": "新增计算模型"
|
||||
"product.product-things-model.142341-203": "新增计算模型",
|
||||
"product.product-modbus.562372-320": "批量",
|
||||
"product.product-modbus.562372-321": "作用:本页 {n} 行",
|
||||
"product.product-modbus.562372-323": "已应用 {n} 行",
|
||||
"product.product-modbus.562372-325": "跳过 {m} 行(数据类型不匹配)",
|
||||
"product.product-modbus.562372-326": "批量删除",
|
||||
"product.product-modbus.562372-327": "请先勾选要删除的行",
|
||||
"product.product-modbus.562372-328": "将删除勾选的 {n} 行(含已保存 {m} 行),确认后需点击保存生效",
|
||||
"product.product-modbus.562372-329": "1(16位)",
|
||||
"product.product-modbus.562372-330": "2(32位)",
|
||||
"product.product-modbus.562372-331": "请先点击「编辑寄存器」进入编辑状态",
|
||||
"product.product-modbus.562372-333": "作用:已勾选 {n} 行",
|
||||
"product.product-modbus.562372-334": "按读取个数递增 · 回车或失焦即生成",
|
||||
"product.product-modbus.562372-335": "一键生成",
|
||||
"product.product-modbus.562372-336": "已生成 {n} 行地址:{from} → {to}",
|
||||
"product.product-modbus.562372-337": "本页有 {m} 处「从机地址 + 寄存器地址」重复,请检查",
|
||||
"product.product-modbus.562372-338": "产品当前为已发布状态,请先点击「取消发布」后再编辑寄存器"
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -88,6 +90,23 @@ const MORE_PANEL_WIDTH = 200
|
||||
// 鼠标横向位移超过这个像素才算拖拽,否则仍按普通点击处理
|
||||
const DRAG_THRESHOLD = 4
|
||||
|
||||
/**
|
||||
* 归一化路由 path:补齐前导斜杠、根路径归一为 '/'
|
||||
*
|
||||
* $route.path 形如 '/iot/device',而 filterAsyncRouter 生成的子路由 path
|
||||
* 形如 'iot/device'(相对父级)。比较路由时必须先归一化,否则匹配不到。
|
||||
*
|
||||
* @param {string} path 原始 path
|
||||
* @returns {string} 归一化后的 path,无效输入返回空串
|
||||
*/
|
||||
function normalizePath(path) {
|
||||
if (!path || typeof path !== 'string') {
|
||||
return ''
|
||||
}
|
||||
const trimmed = path.replace(/^\/+/, '')
|
||||
return '/' + trimmed
|
||||
}
|
||||
|
||||
export default {
|
||||
components: { ScrollPane },
|
||||
data() {
|
||||
@ -264,14 +283,90 @@ export default {
|
||||
this.$store.dispatch('tagsView/addVisitedView', tag)
|
||||
}
|
||||
}
|
||||
// 2. 恢复上次会话「固定到任务栏」的标签(localStorage),保证刷新/重新登录后仍在固定区
|
||||
this.$store.state.tagsView.pinnedViews
|
||||
.filter(tag => tag.name && !affixPaths.includes(tag.path))
|
||||
.forEach(tag => {
|
||||
this.$store.dispatch('tagsView/addView', Object.assign({}, tag, {
|
||||
meta: Object.assign({}, tag.meta, { affix: true, pinned: true })
|
||||
}))
|
||||
// 2. 从后端恢复「固定到任务栏」的标签,跨浏览器/跨设备生效。
|
||||
// 接口异步,必须等返回后再补建固定区,否则 mounted 阶段读到空列表就再也不会重建。
|
||||
getPinnedTags().then(res => {
|
||||
const paths = Array.isArray(res.data) ? res.data : []
|
||||
paths
|
||||
// 路由里已不存在该 path(菜单被删/改名)时丢弃,避免产生打不开的死标签
|
||||
.map(path => this.findRouteByPath(path))
|
||||
.filter(Boolean)
|
||||
.forEach(({ route, path: fullPath }) => {
|
||||
// 路由自身声明的 affix 已在上一步加入,不重复添加
|
||||
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.commit('tagsView/PIN_VIEW', {
|
||||
path: fullPath,
|
||||
fullPath: fullPath,
|
||||
name: route.name,
|
||||
query: {},
|
||||
params: {},
|
||||
meta: Object.assign({}, route.meta, { affix: true, pinned: true })
|
||||
})
|
||||
})
|
||||
}).catch(() => {
|
||||
// 拉取失败就不重建固定区。库里数据不受影响(写入本来就是单条增量的),
|
||||
// 用户重新固定一次即可;这里刻意不做任何清理动作,避免把好东西洗掉。
|
||||
// 必须给提示,否则用户会以为固定标签又丢了
|
||||
Message.warning('固定标签加载失败,刷新页面可重试')
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 按完整路径在当前路由表中递归查找对应路由
|
||||
*
|
||||
* 后端只存了 path,恢复标签时需要 name/query/meta 才能正常渲染与跳转。
|
||||
*
|
||||
* 两个坑:
|
||||
* 1. 路径形态不一致 —— $route.path 形如 '/iot/device'(带前导斜杠),
|
||||
* 而 filterAsyncRouter 生成的子路由 path 形如 'device'(相对父级,不带斜杠)。
|
||||
* 2. 子路由 path 是**相对父级**的,必须逐层拼接成完整路径才能与库里的绝对路径比较;
|
||||
* 只比节点自身 path 的话,'device' 永远匹配不上 '/iot/device'。
|
||||
* 因此这里按「父级完整路径 + 当前节点 path」逐层累积。
|
||||
*
|
||||
* 返回值刻意带上拼出来的 path:命中的是路由**节点**,它自己的 route.path 是相对父级的
|
||||
* ('device'),调用方若拿 route.path 再归一化会得到 '/device' —— 这正是固定标签
|
||||
* 恢复后点开 404 的成因。绝对路径只能由本方法给出,不允许调用方二次推导。
|
||||
*
|
||||
* @param {string} fullPath 目标完整路径,如 /iot/device
|
||||
* @param {Array} routes 待查找的路由列表,默认取全部路由
|
||||
* @param {string} basePath 父级完整路径,递归时内部使用
|
||||
* @returns {{route: Object, path: string}|null} 命中的路由与其绝对路径,未命中返回 null
|
||||
*/
|
||||
findRouteByPath(fullPath, routes, basePath) {
|
||||
const target = normalizePath(fullPath)
|
||||
if (!target) {
|
||||
return null
|
||||
}
|
||||
const list = routes || this.routes
|
||||
const prefix = basePath === undefined ? '' : basePath
|
||||
for (const route of list) {
|
||||
// route.path 已是绝对路径(以 / 开头)时直接用,否则视为相对父级拼接
|
||||
const current = route.path.charAt(0) === '/'
|
||||
? normalizePath(route.path)
|
||||
: normalizePath(prefix + '/' + route.path)
|
||||
if (current === target) {
|
||||
return { route, path: current }
|
||||
}
|
||||
if (route.children && route.children.length) {
|
||||
const hit = this.findRouteByPath(target, route.children, current)
|
||||
if (hit) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
addTags() {
|
||||
const { name } = this.$route
|
||||
|
||||
@ -17,7 +17,10 @@ router.beforeEach((to, from, next) => {
|
||||
to.meta.title && store.dispatch('settings/setTitle', to.meta.title);
|
||||
/* has token*/
|
||||
if (to.path === '/login') {
|
||||
next({ path: '/bigscreen' });
|
||||
// 已登录还访问登录页:回首页。
|
||||
// 这里不再硬编码 /bigscreen —— 登录后的落地页由个人设置决定,
|
||||
// 真正的跳转在 login.vue 的 resolveLoginRedirect 里完成。
|
||||
next({ path: '/' });
|
||||
NProgress.done();
|
||||
} else {
|
||||
if (store.getters.roles.length === 0) {
|
||||
@ -54,7 +57,12 @@ router.beforeEach((to, from, next) => {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
next(`/login?redirect=${to.fullPath}`); // 否则全部重定向到登录页
|
||||
// 只在「用户主动访问的深层页面」上带 redirect 回跳:登录后回原位是这类场景的诉求。
|
||||
// 首页与根路径是应用的默认入口(登出后 location.href='/index'、直接打开站点是 '/'),
|
||||
// 它们不携带任何「用户意图」;一旦也拼进 redirect,login.vue 里
|
||||
// 「redirect 优先于个人设置」的判定会把 sys_user_setting.login_redirect 永久顶掉。
|
||||
const isDefaultEntry = to.path === '/' || to.path === '/index';
|
||||
next(isDefaultEntry ? '/login' : `/login?redirect=${to.fullPath}`);
|
||||
NProgress.done();
|
||||
}
|
||||
}
|
||||
|
||||
@ -71,7 +71,10 @@ export const constantRoutes = [
|
||||
{
|
||||
path: '',
|
||||
component: Layout,
|
||||
redirect: '/bigscreen',
|
||||
// 根路径兜底到首页。系统设置里的「登录后显示页面」不能在这里生效——
|
||||
// 路由表是静态定义的、生成时拿不到异步配置,硬拼会与 permission.js 形成跳转循环。
|
||||
// 实际落地页由 login.vue 的 resolveLoginRedirect 决定,这里只承接「已登录用户访问 /」。
|
||||
redirect: '/index',
|
||||
children: [
|
||||
{
|
||||
path: 'index',
|
||||
|
||||
@ -1,34 +1,12 @@
|
||||
const PINNED_STORAGE_KEY = 'pinned-tags'
|
||||
import { Message } from 'element-ui'
|
||||
import { addPinnedTag, removePinnedTag } from '@/api/system/userSetting'
|
||||
|
||||
// 只持久化「重新打开标签」所需的最小字段,避免把 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 })
|
||||
}
|
||||
}
|
||||
|
||||
function loadPinnedViews() {
|
||||
try {
|
||||
const list = JSON.parse(localStorage.getItem(PINNED_STORAGE_KEY))
|
||||
return Array.isArray(list) ? list : []
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function savePinnedViews(list) {
|
||||
try {
|
||||
localStorage.setItem(PINNED_STORAGE_KEY, JSON.stringify(list))
|
||||
} catch (e) {
|
||||
// localStorage 不可用(隐私模式/超配额)时降级为仅当前会话有效
|
||||
}
|
||||
}
|
||||
// 固定标签的持久化说明:
|
||||
// 固定项落库(sys_user_setting.pinned_tags),按 path 存、顺序即固定区顺序,
|
||||
// 同一账号在任何设备登录都能恢复固定区。
|
||||
// 写入走**服务端单条增量**:固定/取消固定各提交一个 path,前端不再提交自己那份列表,
|
||||
// 因此不存在「拿过期快照整份覆盖」的丢标签问题,多标签页/多端同时操作也不会互相覆盖。
|
||||
// 服务端在事务里「锁定读 → 改 → 写」,顺序由追加先后决定,与这里的固定区顺序一致。
|
||||
|
||||
// 固定区 = 排在普通标签之前的 affix 标签;新固定/取消固定的标签插到固定区末尾
|
||||
function moveToAffixBlockEnd(list, view) {
|
||||
@ -36,12 +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: [],
|
||||
// 用户右键「固定标签到任务栏」的标签,刷新后仍然存在
|
||||
pinnedViews: loadPinnedViews()
|
||||
iframeViews: []
|
||||
}
|
||||
|
||||
const mutations = {
|
||||
@ -107,29 +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))
|
||||
}
|
||||
savePinnedViews(state.pinnedViews)
|
||||
pinIntoAffixBlock(state, view)
|
||||
},
|
||||
UNPIN_VIEW: (state, view) => {
|
||||
state.pinnedViews = state.pinnedViews.filter(v => v.path !== view.path)
|
||||
savePinnedViews(state.pinnedViews)
|
||||
const index = state.visitedViews.findIndex(v => v.path === view.path)
|
||||
if (index === -1) return
|
||||
const target = state.visitedViews[index]
|
||||
@ -204,6 +212,30 @@ const actions = {
|
||||
addVisitedView({ commit }, view) {
|
||||
commit('ADD_VISITED_VIEW', view)
|
||||
},
|
||||
/**
|
||||
* 固定到任务栏
|
||||
*
|
||||
* 本地先变、再落库;落库失败就回滚本地状态并提示,避免「界面显示已固定、实际没存上」。
|
||||
* 提交的是**单个 path**——服务端按增量写入,不会碰到其它标签页/其它设备刚做的改动,
|
||||
* 因此这里不需要先拉一次全量列表,也就不用担心那份列表已经过期。
|
||||
*/
|
||||
pinView({ commit }, view) {
|
||||
commit('PIN_VIEW', view)
|
||||
return addPinnedTag(view.path).catch(() => {
|
||||
commit('UNPIN_VIEW', view)
|
||||
Message.warning('固定标签保存失败,请重试')
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 取消固定
|
||||
*/
|
||||
unpinView({ commit }, view) {
|
||||
commit('UNPIN_VIEW', view)
|
||||
return removePinnedTag(view.path).catch(() => {
|
||||
commit('PIN_VIEW', view)
|
||||
Message.warning('取消固定保存失败,请重试')
|
||||
})
|
||||
},
|
||||
addCachedView({ commit }, view) {
|
||||
commit('ADD_CACHED_VIEW', view)
|
||||
},
|
||||
@ -282,14 +314,6 @@ const actions = {
|
||||
updateVisitedView({ commit }, view) {
|
||||
commit('UPDATE_VISITED_VIEW', view)
|
||||
},
|
||||
// 固定标签到任务栏(标签栏最左侧固定区,刷新后仍存在)
|
||||
pinView({ commit }, view) {
|
||||
commit('PIN_VIEW', view)
|
||||
},
|
||||
// 取消固定
|
||||
unpinView({ commit }, view) {
|
||||
commit('UNPIN_VIEW', view)
|
||||
},
|
||||
// 拖拽排序
|
||||
sortVisitedViews({ commit }, payload) {
|
||||
commit('SORT_VISITED_VIEWS', payload)
|
||||
|
||||
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>
|
||||
@ -47,16 +47,34 @@
|
||||
<el-form-item label="设备">
|
||||
<el-select
|
||||
v-model="queryParams.deviceId"
|
||||
placeholder="请选择设备"
|
||||
placeholder="请选择设备(可输入名称/编号检索)"
|
||||
filterable
|
||||
clearable
|
||||
@change="handleDeviceChange"
|
||||
style="width: 220px"
|
||||
style="width: 280px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in deviceList"
|
||||
:key="item.deviceId"
|
||||
:label="`${item.deviceName} (${item.serialNumber})`"
|
||||
:label="item.label"
|
||||
:value="item.deviceId"
|
||||
></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<!-- 选中网关且其下挂有子设备时动态出现;不选则修复网关本体的物模型 -->
|
||||
<el-form-item v-if="gatewayChildren.length > 0" label="子设备">
|
||||
<el-select
|
||||
v-model="queryParams.subDeviceId"
|
||||
placeholder="不选则修复网关本体"
|
||||
filterable
|
||||
clearable
|
||||
@change="handleSubDeviceChange"
|
||||
style="width: 240px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in gatewayChildren"
|
||||
:key="item.deviceId"
|
||||
:label="item.label"
|
||||
:value="item.deviceId"
|
||||
></el-option>
|
||||
</el-select>
|
||||
@ -124,7 +142,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 自动校准配置弹窗 -->
|
||||
<el-dialog title="自动校准配置" :visible.sync="calibrationConfigVisible" width="600px" append-to-body v-dialogDrag>
|
||||
<el-dialog title="自动校准配置" :visible.sync="calibrationConfigVisible" width="720px" append-to-body v-dialogDrag>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
@ -170,7 +188,24 @@
|
||||
<el-option
|
||||
v-for="item in deviceList"
|
||||
:key="item.deviceId"
|
||||
:label="`${item.deviceName} (${item.serialNumber})`"
|
||||
:label="item.label"
|
||||
:value="item.deviceId"
|
||||
></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="configGatewayChildren.length > 0" label="子设备">
|
||||
<el-select
|
||||
v-model="newConfig.subDeviceId"
|
||||
placeholder="不选则配置网关本体"
|
||||
filterable
|
||||
clearable
|
||||
@change="handleConfigSubDeviceChange"
|
||||
style="width: 200px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in configGatewayChildren"
|
||||
:key="item.deviceId"
|
||||
:label="item.label"
|
||||
:value="item.deviceId"
|
||||
></el-option>
|
||||
</el-select>
|
||||
@ -337,7 +372,7 @@
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
import { listDeviceShort, listThingsModel } from '@/api/iot/device.js';
|
||||
import { listDeviceTree, listThingsModel } from '@/api/iot/device.js';
|
||||
import {
|
||||
getRepairDataList,
|
||||
addDeviceLog,
|
||||
@ -367,13 +402,16 @@ export default {
|
||||
calibrationConfigList: [],
|
||||
newConfig: {
|
||||
deviceId: null,
|
||||
subDeviceId: null,
|
||||
identifier: null
|
||||
},
|
||||
configGatewayChildren: [],
|
||||
configIdentifierList: [],
|
||||
|
||||
// 查询参数
|
||||
queryParams: {
|
||||
deviceId: null,
|
||||
subDeviceId: null,
|
||||
identifier: null,
|
||||
dateRange: [
|
||||
moment().subtract(24, 'hours').format('YYYY-MM-DD HH:mm:ss'),
|
||||
@ -384,7 +422,10 @@ export default {
|
||||
},
|
||||
|
||||
// 数据
|
||||
// 设备栏只放顶层设备(网关 + 直连设备);网关的子设备走按需出现的「子设备」栏
|
||||
deviceList: [],
|
||||
deviceMap: {},
|
||||
gatewayChildren: [],
|
||||
identifierList: [],
|
||||
tableData: [],
|
||||
total: 0,
|
||||
@ -450,17 +491,22 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// 主设备(网关或直连设备)
|
||||
mainDevice() {
|
||||
return this.deviceMap[this.queryParams.deviceId];
|
||||
},
|
||||
// 有效设备:选了子设备就是子设备,否则是主设备(网关本体)
|
||||
currentDevice() {
|
||||
return this.deviceMap[this.queryParams.subDeviceId] || this.mainDevice;
|
||||
},
|
||||
currentDeviceName() {
|
||||
const device = this.deviceList.find(d => d.deviceId === this.queryParams.deviceId);
|
||||
const device = this.currentDevice;
|
||||
return device ? `${device.deviceName} (${device.serialNumber})` : '';
|
||||
},
|
||||
currentModelName() {
|
||||
const model = this.identifierList.find(m => m.identifier === this.queryParams.identifier);
|
||||
return model ? model.modelName : '';
|
||||
},
|
||||
currentDevice() {
|
||||
return this.deviceList.find(d => d.deviceId === this.queryParams.deviceId);
|
||||
},
|
||||
currentModel() {
|
||||
return this.identifierList.find(m => m.identifier === this.queryParams.identifier);
|
||||
}
|
||||
@ -506,19 +552,27 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// 配置设备变化
|
||||
// 配置:主设备变化 → 重置子设备与物模型,并按需挂出该网关的子设备
|
||||
async handleConfigDeviceChange(deviceId) {
|
||||
this.newConfig.subDeviceId = null;
|
||||
this.newConfig.identifier = null;
|
||||
this.configGatewayChildren = [];
|
||||
this.configIdentifierList = [];
|
||||
if (!deviceId) return;
|
||||
try {
|
||||
const res = await listThingsModel({ deviceId, pageNum: 1, pageSize: 9999 });
|
||||
if (res.code === 200) {
|
||||
this.configIdentifierList = res.rows || [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载物模型失败', e);
|
||||
const device = this.deviceMap[deviceId];
|
||||
if (device && device.children.length) {
|
||||
this.configGatewayChildren = device.children;
|
||||
}
|
||||
this.configIdentifierList = await this.fetchIdentifierList(device);
|
||||
},
|
||||
|
||||
// 配置:子设备变化(清空则回到网关本体)
|
||||
async handleConfigSubDeviceChange(subDeviceId) {
|
||||
this.newConfig.identifier = null;
|
||||
this.configIdentifierList = [];
|
||||
const deviceId = subDeviceId || this.newConfig.deviceId;
|
||||
if (!deviceId) return;
|
||||
this.configIdentifierList = await this.fetchIdentifierList(this.deviceMap[deviceId]);
|
||||
},
|
||||
|
||||
// 添加自动校准配置
|
||||
@ -526,7 +580,7 @@ export default {
|
||||
if (!this.newConfig.deviceId || !this.newConfig.identifier) {
|
||||
return this.$message.warning('请选择设备和物模型');
|
||||
}
|
||||
const device = this.deviceList.find(d => d.deviceId === this.newConfig.deviceId);
|
||||
const device = this.deviceMap[this.newConfig.subDeviceId || this.newConfig.deviceId];
|
||||
const model = this.configIdentifierList.find(m => m.identifier === this.newConfig.identifier);
|
||||
if (!device || !model) {
|
||||
return this.$message.error('设备或物模型不存在');
|
||||
@ -581,35 +635,81 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// 加载设备列表
|
||||
// 加载设备树:主栏只取顶层设备(网关 + 直连设备),子设备递归挂在所属网关的 children 上
|
||||
async loadDeviceList() {
|
||||
try {
|
||||
const res = await listDeviceShort({ showChild: true, pageNum: 1, pageSize: 9999 });
|
||||
if (res.code === 200) {
|
||||
this.deviceList = res.rows || [];
|
||||
}
|
||||
const res = await listDeviceTree({ showChild: true });
|
||||
if (res.code !== 200) return;
|
||||
const deviceMap = {};
|
||||
// 节点 → 下拉项;label 带编号(filterable 可按名称或编号检索),并同步写进 deviceId 索引
|
||||
const convert = (node) => {
|
||||
const option = {
|
||||
deviceId: node.deviceId,
|
||||
deviceName: node.deviceName,
|
||||
serialNumber: node.serialNumber,
|
||||
productId: node.productId,
|
||||
productName: node.productName,
|
||||
deviceType: node.deviceType,
|
||||
children: [],
|
||||
label: `${node.deviceType === 2 ? '🌐 ' : ''}${node.deviceName || node.serialNumber} (${node.serialNumber})`
|
||||
};
|
||||
deviceMap[node.deviceId] = option;
|
||||
option.children = (node.children || []).map(convert);
|
||||
return option;
|
||||
};
|
||||
const roots = (res.data || []).map(convert);
|
||||
// 整体替换而非逐键写入,保证 deviceMap 的响应式失效
|
||||
this.deviceMap = deviceMap;
|
||||
this.deviceList = roots;
|
||||
} catch (e) {
|
||||
console.error('加载设备列表失败', e);
|
||||
console.error('加载设备树失败', e);
|
||||
}
|
||||
},
|
||||
|
||||
// 设备变化
|
||||
// 设备变化:清空子设备栏与物模型;是网关则挂出子设备,并默认按网关本体加载物模型
|
||||
async handleDeviceChange(deviceId) {
|
||||
this.queryParams.subDeviceId = null;
|
||||
this.queryParams.identifier = null;
|
||||
this.gatewayChildren = [];
|
||||
this.identifierList = [];
|
||||
this.tableData = [];
|
||||
this.total = 0;
|
||||
|
||||
if (!deviceId) return;
|
||||
|
||||
try {
|
||||
const res = await listThingsModel({ deviceId, pageNum: 1, pageSize: 9999 });
|
||||
if (res.code === 200) {
|
||||
// 显示所有物模型(属性type=1、功能type=2、事件type=3)
|
||||
this.identifierList = res.rows || [];
|
||||
const device = this.deviceMap[deviceId];
|
||||
if (device && device.children.length) {
|
||||
this.gatewayChildren = device.children;
|
||||
}
|
||||
|
||||
// 网关本体没有物模型时,物模型栏就空着,由用户去「子设备」里选
|
||||
this.identifierList = await this.fetchIdentifierList(device);
|
||||
},
|
||||
|
||||
// 子设备变化:选中则修复该子设备,清空则回到网关本体
|
||||
async handleSubDeviceChange(subDeviceId) {
|
||||
this.queryParams.identifier = null;
|
||||
this.identifierList = [];
|
||||
this.tableData = [];
|
||||
this.total = 0;
|
||||
|
||||
const deviceId = subDeviceId || this.queryParams.deviceId;
|
||||
if (!deviceId) return;
|
||||
this.identifierList = await this.fetchIdentifierList(this.deviceMap[deviceId]);
|
||||
},
|
||||
|
||||
// 查设备「自己」的物模型(属性type=1、功能type=2、事件type=3)
|
||||
// ⚠️ /iot/device/listThingsModel 会把网关下所有子设备的物模型也合并返回(带 parentSerialNumber),
|
||||
// 修复页只认选中设备自身的物模型,否则子设备的标识符会被当成网关的物模型去查询/新增
|
||||
async fetchIdentifierList(device) {
|
||||
if (!device) return [];
|
||||
try {
|
||||
const res = await listThingsModel({ deviceId: device.deviceId, pageNum: 1, pageSize: 9999 });
|
||||
const rows = res.code === 200 ? (res.rows || []) : [];
|
||||
return rows.filter(model => model.serialNumber === device.serialNumber);
|
||||
} catch (e) {
|
||||
console.error('加载物模型失败', e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
@ -632,6 +732,7 @@ export default {
|
||||
handleReset() {
|
||||
this.queryParams = {
|
||||
deviceId: null,
|
||||
subDeviceId: null,
|
||||
identifier: null,
|
||||
dateRange: [
|
||||
moment().subtract(24, 'hours').format('YYYY-MM-DD HH:mm:ss'),
|
||||
@ -640,6 +741,7 @@ export default {
|
||||
pageNum: 1,
|
||||
pageSize: 50
|
||||
};
|
||||
this.gatewayChildren = [];
|
||||
this.identifierList = [];
|
||||
this.tableData = [];
|
||||
this.total = 0;
|
||||
|
||||
@ -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">
|
||||
@ -115,13 +120,6 @@
|
||||
</el-dropdown-menu>
|
||||
</el-dropdown>
|
||||
</el-col>
|
||||
<el-col :span="1.5" style="line-height: 28px">
|
||||
<el-checkbox v-model="queryParams.showChild" style="margin: 0px 10px" @change="handleQuery">
|
||||
{{ $t('device.index.105953-18') }}
|
||||
</el-checkbox>
|
||||
<el-tooltip :content="$t('device.index.105953-19')" placement="top"><i class="el-icon-question"></i>
|
||||
</el-tooltip>
|
||||
</el-col>
|
||||
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList">
|
||||
<template>
|
||||
<el-tooltip effect="dark" content="切换卡片/列表" placement="top">
|
||||
@ -396,6 +394,7 @@ import vueQr from 'vue-qr';
|
||||
import {listDeviceShort, delDevice} from '@/api/iot/device';
|
||||
import {listGroup} from '@/api/iot/group';
|
||||
import {deptsTreeSelect} from '@/api/system/user';
|
||||
import {getUserSetting} from '@/api/system/userSetting';
|
||||
import {delSipDeviceBySipId} from '@/api/iot/sipdevice';
|
||||
import auth from '@/plugins/auth';
|
||||
import Treeselect from '@riophae/vue-treeselect';
|
||||
@ -405,6 +404,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 +417,7 @@ export default {
|
||||
importRecord,
|
||||
recycleRecord,
|
||||
allotRecord,
|
||||
BatchSerialNumberInput,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@ -440,12 +441,16 @@ export default {
|
||||
myGroupList: [],
|
||||
// 机构树
|
||||
deptOptions: [],
|
||||
// 「是否包含下级机构数据」是否已从个人设置同步完成(首次同步由 created 负责)
|
||||
showChildLoaded: false,
|
||||
// 根路径
|
||||
baseUrl: process.env.VUE_APP_BASE_API,
|
||||
// 查询参数
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 12,
|
||||
// 「是否包含下级机构数据」由个人设置(系统管理 → 系统设置)决定,
|
||||
// 进入页面时由 syncShowChild() 覆盖,页面内不再就地修改
|
||||
showChild: false,
|
||||
entrance: 1,
|
||||
deviceName: null,
|
||||
@ -519,13 +524,13 @@ export default {
|
||||
this.queryParams.productId = null;
|
||||
this.queryParams.groupId = null;
|
||||
}
|
||||
// 恢复"显示下级机构数据"用户选择
|
||||
const cachedShowChild = this.$cache.local.get('device-showChild');
|
||||
if (cachedShowChild === true || cachedShowChild === 'true') {
|
||||
this.queryParams.showChild = true;
|
||||
}
|
||||
this.getDeptTree();
|
||||
// 先把「是否包含下级机构数据」从个人设置同步过来再拉列表,
|
||||
// 避免先用默认值查一次、拿到设置后又重查一次的闪动
|
||||
this.syncShowChild().then(() => {
|
||||
this.showChildLoaded = true;
|
||||
this.connectMqtt();
|
||||
});
|
||||
},
|
||||
activated() {
|
||||
// 页码筛选(优先恢复页码,无论是否有其他参数变化)
|
||||
@ -573,8 +578,46 @@ export default {
|
||||
// 浏览器回退时,即使没有 t 参数也要刷新列表
|
||||
this.getList();
|
||||
}
|
||||
this.resyncShowChild();
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 同步「设备管理是否包含下级机构数据」
|
||||
*
|
||||
* 该偏好由系统设置页维护并落 sys_user_setting,本页只读不写。
|
||||
* 拉取失败时保持默认 false(不写坏值),并返回是否发生了变化,
|
||||
* 供 activated 判断是否需要重查列表。
|
||||
*
|
||||
* @returns {Promise<boolean>} 值是否发生变化
|
||||
*/
|
||||
syncShowChild() {
|
||||
return getUserSetting()
|
||||
.then((response) => {
|
||||
const next = !!(response.data && response.data.showChild);
|
||||
const changed = this.queryParams.showChild !== next;
|
||||
this.queryParams.showChild = next;
|
||||
return changed;
|
||||
})
|
||||
.catch(() => false);
|
||||
},
|
||||
/**
|
||||
* 页面被重新激活时同步偏好
|
||||
*
|
||||
* 本页被 keep-alive 常驻时切回来不会重跑 created,若不在激活时重新同步,
|
||||
* 在系统设置页改了开关再切回本页将看不到变化。
|
||||
* 首次进入由 created 负责(它同步完才拉列表),此处跳过,
|
||||
* 否则会和 created 抢跑、让列表在 MQTT 连上之前就查一次。
|
||||
*/
|
||||
resyncShowChild() {
|
||||
if (!this.showChildLoaded) {
|
||||
return;
|
||||
}
|
||||
this.syncShowChild().then((changed) => {
|
||||
if (changed) {
|
||||
this.getList();
|
||||
}
|
||||
});
|
||||
},
|
||||
/* 连接Mqtt消息服务器 */
|
||||
async connectMqtt() {
|
||||
if (this.$mqttTool.client == null) {
|
||||
@ -788,7 +831,6 @@ export default {
|
||||
/** 搜索按钮操作 */
|
||||
handleQuery() {
|
||||
this.queryParams.pageNum = 1;
|
||||
this.$cache.local.set('device-showChild', this.queryParams.showChild);
|
||||
this.getList();
|
||||
},
|
||||
/** 重置按钮操作 */
|
||||
@ -797,8 +839,8 @@ export default {
|
||||
this.queryParams.groupId = null;
|
||||
this.queryParams.serialNumber = null;
|
||||
this.queryParams.deptId = null;
|
||||
this.queryParams.showChild = false;
|
||||
this.$cache.local.remove('device-showChild');
|
||||
// 刻意不重置 showChild:「是否包含下级机构数据」是个人设置里的持久偏好,
|
||||
// 重置按钮只管本次查询条件,改了偏好下次进页面又会被覆盖回来,反而不一致
|
||||
this.resetForm('queryForm');
|
||||
this.handleQuery();
|
||||
},
|
||||
|
||||
254
yack-web/src/views/iot/product/components/modbusColumnBatch.vue
Normal file
254
yack-web/src/views/iot/product/components/modbusColumnBatch.vue
Normal file
@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<el-popover placement="bottom-start" width="240" trigger="click" :disabled="disabled" v-model="visible">
|
||||
<span slot="reference" class="modbus-col-batch-header" :class="{ 'is-disabled': disabled }" @click.stop="handleTriggerClick">
|
||||
<span class="modbus-col-batch-label">{{ label }}</span>
|
||||
<span class="modbus-col-batch-badge" :class="{ 'is-magic': column === 'addressAuto' }" :title="column === 'addressAuto' ? $t('product.product-modbus.562372-335') : ''">
|
||||
<i v-if="column === 'addressAuto'" class="el-icon-magic-stick"></i>
|
||||
<template v-else>{{ $t('product.product-modbus.562372-320') }}</template>
|
||||
</span>
|
||||
</span>
|
||||
<div class="modbus-col-batch-panel">
|
||||
<div class="modbus-col-batch-scope">{{ scopeText }}</div>
|
||||
|
||||
<!-- 从机地址:数字回车 / 步进变化即生效 -->
|
||||
<div v-if="column === 'slave'" class="modbus-col-batch-field">
|
||||
<el-input-number v-model="slaveValue" size="mini" :min="1" :max="256" :label="$t('product.product-modbus.562372-29')" @change="handleInstantApply" @keyup.enter.native="handleNumberEnter"></el-input-number>
|
||||
</div>
|
||||
|
||||
<!-- 是否只读(编码红线:0=只读、1=读写) -->
|
||||
<div v-else-if="column === 'isReadonly'" class="modbus-col-batch-field">
|
||||
<el-radio-group v-model="readonlyValue" @change="handleInstantApply">
|
||||
<el-radio :label="0">{{ $t('product.product-modbus.562372-32') }}</el-radio>
|
||||
<el-radio :label="1">{{ $t('product.product-modbus.562372-33') }}</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- 位偏移量(IO 寄存器):数字回车 / 步进变化即生效 -->
|
||||
<div v-else-if="column === 'bitOrder'" class="modbus-col-batch-field">
|
||||
<el-input-number v-model="bitOrderValue" size="mini" :min="0" :max="15" :label="$t('product.product-modbus.562372-311')" @change="handleInstantApply" @keyup.enter.native="handleNumberEnter"></el-input-number>
|
||||
</div>
|
||||
|
||||
<!-- 数据类型:选中即写入并联动读取个数,32 位类型在选项上标出 · 2 -->
|
||||
<div v-else-if="column === 'dataType'" class="modbus-col-batch-field">
|
||||
<el-select v-model="dataTypeValue" size="mini" :placeholder="$t('product.product-modbus.562372-42')" @change="handleInstantApply">
|
||||
<el-option v-for="dict in dict.type.iot_modbus_data_type" :key="dict.value" :label="dictOptionLabel(dict)" :value="dict.value"></el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 读取个数:选中即写入,数据类型不匹配的行跳过 -->
|
||||
<div v-else-if="column === 'quantity'" class="modbus-col-batch-field">
|
||||
<el-radio-group v-model="quantityValue" @change="handleInstantApply">
|
||||
<el-radio :label="1">{{ $t('product.product-modbus.562372-329') }}</el-radio>
|
||||
<el-radio :label="2">{{ $t('product.product-modbus.562372-330') }}</el-radio>
|
||||
</el-radio-group>
|
||||
<div class="modbus-col-batch-tip" v-if="quantityValue && quantitySkipCount > 0">
|
||||
{{ $t('product.product-modbus.562372-325', { m: quantitySkipCount }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 寄存器地址一键生成:填起始地址,回车(或改完失焦)即按各行读取个数依次生成 -->
|
||||
<div v-else-if="column === 'addressAuto'" class="modbus-col-batch-field">
|
||||
<el-input
|
||||
v-model.number="startAddressValue"
|
||||
size="mini"
|
||||
:placeholder="$t('product.product-modbus.562372-30')"
|
||||
@change="handleGenerateAddress"
|
||||
@keyup.enter.native="handleGenerateAddress"
|
||||
></el-input>
|
||||
<div class="modbus-col-batch-tip is-hint">{{ $t('product.product-modbus.562372-334') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { deriveQuantity } from '../modbus-derive';
|
||||
|
||||
/**
|
||||
* 列头批量面板:一列一个入口,谁的值谁负责。
|
||||
* 交互约定(2026-10-06 简化):
|
||||
* - 整个列头即入口(列名 + 「批量」角标),点哪都能开;
|
||||
* - **选择即生效**:下拉/单选选中即写入并收起;数字回车或步进变化即写入(无「应用」按钮);
|
||||
* - 作用域由父组件通过 scopeRows + scopeMode 传入(有勾选行→勾选行,否则本页全部行)。
|
||||
*/
|
||||
export default {
|
||||
name: 'modbus-column-batch',
|
||||
dicts: ['iot_modbus_data_type'],
|
||||
props: {
|
||||
// 列头显示名(父组件传入 i18n 文案)
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// 列标识:slave / isReadonly / bitOrder / dataType / quantity
|
||||
column: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
// 仅编辑态可操作
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// 禁用时的提示文案(由父组件按真实原因给出:产品已发布 / 未进入编辑态)
|
||||
disabledTip: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// 作用行:勾选行优先,否则本页全部行(父组件算好传入)
|
||||
scopeRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
// 作用域来源:all=本页全部行,selected=勾选行
|
||||
scopeMode: {
|
||||
type: String,
|
||||
default: 'all',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
visible: false,
|
||||
// 本次打开面板是否已提交(防止 input-number 的 change 与 keyup.enter 双触发)
|
||||
applied: false,
|
||||
// 初值与"混合态"一致用 undefined(el-input-number 会把 null 解析成 0)
|
||||
slaveValue: undefined,
|
||||
readonlyValue: undefined,
|
||||
bitOrderValue: undefined,
|
||||
dataTypeValue: undefined,
|
||||
quantityValue: undefined,
|
||||
startAddressValue: undefined,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
scopeText() {
|
||||
const key = this.scopeMode === 'selected' ? 'product.product-modbus.562372-333' : 'product.product-modbus.562372-321';
|
||||
return this.$t(key, { n: this.scopeRows.length });
|
||||
},
|
||||
// 数据类型非空且派生读取个数与所选值不符的行数(这些行将跳过,不写出自相矛盾的行)
|
||||
quantitySkipCount() {
|
||||
if (this.column !== 'quantity' || !this.quantityValue) return 0;
|
||||
return this.scopeRows.filter((row) => row.dataType && deriveQuantity(row.dataType) !== this.quantityValue).length;
|
||||
},
|
||||
// 一键生成需要明确的起始地址(0 是合法值,不能用真值判断)
|
||||
canGenerate() {
|
||||
const value = this.startAddressValue;
|
||||
if (value === undefined || value === null || value === '') return false;
|
||||
const num = Number(value);
|
||||
return !isNaN(num) && num >= 0;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
// 打开时预填作用行的公共值:全部一致就显示该值,不一致则留空(提示值为混合态)
|
||||
visible(val) {
|
||||
if (val) this.syncFromScope();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
syncFromScope() {
|
||||
this.applied = false;
|
||||
const pick = (field) => {
|
||||
const rows = this.scopeRows;
|
||||
if (!rows.length) return undefined;
|
||||
const first = rows[0][field];
|
||||
return rows.every((row) => row[field] === first) ? first : undefined;
|
||||
};
|
||||
this.slaveValue = pick('slave');
|
||||
this.readonlyValue = pick('isReadonly');
|
||||
this.bitOrderValue = pick('bitOrder');
|
||||
this.dataTypeValue = pick('dataType');
|
||||
this.quantityValue = pick('quantity');
|
||||
// 起始地址预填作用域首行地址(重排时通常保留起点)
|
||||
this.startAddressValue = this.scopeRows.length ? this.scopeRows[0].address : undefined;
|
||||
},
|
||||
// 32 位类型在选项后标出占用的寄存器个数,选之前就知道会写 2
|
||||
dictOptionLabel(dict) {
|
||||
return deriveQuantity(dict.value) === 2 ? `${dict.label} · 2` : dict.label;
|
||||
},
|
||||
handleTriggerClick() {
|
||||
if (this.disabled) {
|
||||
this.$modal.msgWarning(this.disabledTip || this.$t('product.product-modbus.562372-331'));
|
||||
}
|
||||
},
|
||||
// 数字输入框回车:失焦以触发 el-input-number 的提交(它自身不响应回车),值有变化才会 emit change
|
||||
handleNumberEnter(event) {
|
||||
if (event && event.target && event.target.blur) event.target.blur();
|
||||
},
|
||||
// 选择即生效:值变化即提交并收起面板
|
||||
handleInstantApply(value) {
|
||||
if (this.applied) return;
|
||||
if (value === null || value === undefined || value === '') return;
|
||||
this.applied = true;
|
||||
this.$emit('apply', { column: this.column, value });
|
||||
this.visible = false;
|
||||
},
|
||||
// 地址一键生成:起始地址回车、或改完失焦即生成(无需按钮;同一面板只提交一次)
|
||||
// 注意这里用 el-input 而非 el-input-number:后者的 v-model 只在失焦/步进时提交,回车取到的会是旧值
|
||||
handleGenerateAddress() {
|
||||
if (this.applied || !this.canGenerate) return;
|
||||
this.applied = true;
|
||||
this.$emit('apply', { column: this.column, value: Number(this.startAddressValue) });
|
||||
this.visible = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.modbus-col-batch-header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
.modbus-col-batch-label {
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.modbus-col-batch-badge {
|
||||
margin-left: 4px;
|
||||
font-size: 11px;
|
||||
color: #c0c4cc;
|
||||
line-height: 1;
|
||||
|
||||
&.is-magic {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .modbus-col-batch-badge {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
cursor: not-allowed;
|
||||
|
||||
.modbus-col-batch-badge {
|
||||
color: #e4e7ed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modbus-col-batch-panel {
|
||||
.modbus-col-batch-scope {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.modbus-col-batch-field {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.modbus-col-batch-tip {
|
||||
font-size: 12px;
|
||||
color: #e6a23c;
|
||||
line-height: 18px;
|
||||
margin-top: 6px;
|
||||
|
||||
&.is-hint {
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
44
yack-web/src/views/iot/product/modbus-derive.js
Normal file
44
yack-web/src/views/iot/product/modbus-derive.js
Normal file
@ -0,0 +1,44 @@
|
||||
/**
|
||||
* modbus 配置派生规则(唯一口径,页面与批量面板共用)
|
||||
*
|
||||
* 红线:`读取个数(quantity)` 是 `数据类型(dataType)` 的派生量,不是独立字段。
|
||||
* 后端 ModbusProtocol#handleRead0304 完全按 quantity 分支解析:
|
||||
* quantity==1 → 16 位(CalculateUtils.handleToUnSign16)
|
||||
* quantity==2 → 32 位(CalculateUtils.parseValue)
|
||||
* 其它值 → log.warn 直接丢弃
|
||||
* 字典 iot_modbus_data_type(sql/yack-init.sql:2503-2511):
|
||||
* bit=1 位;ushort/short=16 位 1 个寄存器;long/ulong/float 的 ABCD/CDAB 变体=32 位 2 个寄存器。
|
||||
*/
|
||||
|
||||
/** 32 位数据类型集合(占 2 个寄存器) */
|
||||
export const MODBUS_32BIT_TYPES = ['long-ABCD', 'long-CDAB', 'ulong-ABCD', 'ulong-CDAB', 'float-ABCD', 'float-CDAB'];
|
||||
|
||||
/** 16 位数据类型集合(占 1 个寄存器) */
|
||||
export const MODBUS_16BIT_TYPES = ['ushort', 'short'];
|
||||
|
||||
/**
|
||||
* 由数据类型推导读取个数
|
||||
* @param {string} dataType 字典 iot_modbus_data_type 的值
|
||||
* @returns {number|null} 1 | 2;dataType 为空或未知类型返回 null(不派生,避免把 data_type 为空的历史行改坏)
|
||||
*/
|
||||
export function deriveQuantity(dataType) {
|
||||
if (!dataType) return null;
|
||||
if (dataType === 'bit') return 1;
|
||||
if (MODBUS_16BIT_TYPES.indexOf(dataType) !== -1) return 1;
|
||||
if (MODBUS_32BIT_TYPES.indexOf(dataType) !== -1) return 2;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单行占用的寄存器个数(一 key 生成地址序列时的步长)
|
||||
* dataType 非空 → 以派生值为准(权威);dataType 为空的历史行 → 回退行内 quantity;再缺省 1
|
||||
* @param {object} row modbus 配置行
|
||||
* @returns {number} 1 | 2 | 行内 quantity
|
||||
*/
|
||||
export function resolveQuantity(row) {
|
||||
if (!row) return 1;
|
||||
const derived = deriveQuantity(row.dataType);
|
||||
if (derived !== null) return derived;
|
||||
const quantity = Number(row.quantity);
|
||||
return quantity > 0 ? quantity : 1;
|
||||
}
|
||||
@ -93,7 +93,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table style="margin-bottom: 10px" v-loading="loadingIO" :data="configList" :key="configTableKey" data-key="id" ref="IOTable">
|
||||
<el-table style="margin-bottom: 10px" v-loading="loadingIO" :data="configList" :key="configTableKey" data-key="id" ref="IOTable" @selection-change="handleIoSelectionChange">
|
||||
<el-table-column type="selection" width="45" align="center" :selectable="ioSelectable" />
|
||||
<el-table-column :label="$t('product.product-modbus.562372-24')" align="center" prop="sort"></el-table-column>
|
||||
<el-table-column :label="$t('product.product-modbus.562372-25')" align="center" prop="identifier">
|
||||
<template slot-scope="scope">
|
||||
@ -102,15 +103,13 @@
|
||||
:disabled="!enableEditIO"
|
||||
v-model="scope.row.identifier"
|
||||
:placeholder="$t('product.product-modbus.562372-26')"
|
||||
:ref="`selectIo${scope.$index}`"
|
||||
size="mini"
|
||||
@change="(e) => updateSelectThingsModel({ newVal: e, oldVal: $refs[`selectIo${scope.$index}`].value, justiceSelect: 'isSelectIo' })"
|
||||
>
|
||||
<el-option key="0" label="" value="" disabled style="width: 300px">
|
||||
<span style="float: left">{{ $t('product.product-modbus.562372-27') }}</span>
|
||||
<span style="float: right; color: #8492a6; font-size: 13px">{{ $t('product.product-modbus.562372-28') }}</span>
|
||||
</el-option>
|
||||
<el-option v-for="item in thingsModelList" :key="item.identifier" :label="`${item.modelName} (${item.identifier})`" :value="item.identifier" style="width: 300px" :disabled="!item.isSelectIo">
|
||||
<el-option v-for="item in thingsModelList" :key="item.identifier" :label="`${item.modelName} (${item.identifier})`" :value="item.identifier" style="width: 300px" :disabled="isOptionDisabled(item, 'isSelectIo')">
|
||||
<span style="float: left">{{ item.modelName }}</span>
|
||||
<span style="float: right; color: #8492a6; font-size: 13px">{{ item.identifier }}</span>
|
||||
</el-option>
|
||||
@ -118,6 +117,9 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('product.product-modbus.562372-29')" align="center" prop="slave">
|
||||
<template slot="header">
|
||||
<modbus-column-batch :label="$t('product.product-modbus.562372-29')" column="slave" :disabled="!enableEditIO" :scope-rows="ioScopeRows" :scope-mode="ioScopeMode" :disabled-tip="editDisabledTip" @apply="(p) => onColumnApply('io', p)" />
|
||||
</template>
|
||||
<template slot-scope="scope">
|
||||
<el-input-number :disabled="!enableEditIO" v-model="scope.row.slave" size="small" :min="1" :max="256" :label="$t('product.product-modbus.562372-29')"></el-input-number>
|
||||
</template>
|
||||
@ -128,19 +130,31 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('product.product-modbus.562372-31')" align="center" prop="isReadonly">
|
||||
<template slot="header">
|
||||
<modbus-column-batch :label="$t('product.product-modbus.562372-31')" column="isReadonly" :disabled="!enableEditIO" :scope-rows="ioScopeRows" :scope-mode="ioScopeMode" :disabled-tip="editDisabledTip" @apply="(p) => onColumnApply('io', p)" />
|
||||
</template>
|
||||
<template slot-scope="scope">
|
||||
<el-radio-group :disabled="!enableEditIO" size="mini" v-model="scope.row.isReadonly">
|
||||
<el-radio-button label="0">{{ $t('product.product-modbus.562372-32') }}</el-radio-button>
|
||||
<el-radio-button label="1">{{ $t('product.product-modbus.562372-33') }}</el-radio-button>
|
||||
<el-radio-button :label="0">{{ $t('product.product-modbus.562372-32') }}</el-radio-button>
|
||||
<el-radio-button :label="1">{{ $t('product.product-modbus.562372-33') }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('product.product-modbus.562372-311')" align="center" prop="bitOrder">
|
||||
<template slot="header">
|
||||
<modbus-column-batch :label="$t('product.product-modbus.562372-311')" column="bitOrder" :disabled="!enableEditIO" :scope-rows="ioScopeRows" :scope-mode="ioScopeMode" :disabled-tip="editDisabledTip" @apply="(p) => onColumnApply('io', p)" />
|
||||
</template>
|
||||
<template slot-scope="scope">
|
||||
<el-input-number :disabled="!enableEditIO" v-model="scope.row.bitOrder" size="small" :min="0" :max="15" :label="$t('product.product-modbus.562372-311')"></el-input-number>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('product.product-modbus.562372-34')" align="center" class-name="small-padding fixed-width">
|
||||
<template slot="header">
|
||||
<span>{{ $t('product.product-modbus.562372-34') }}</span>
|
||||
<el-button type="text" size="mini" icon="el-icon-delete" :disabled="!enableEditIO" @click="handleBatchDelete('io')" v-hasPermi="['iot:config:remove']">
|
||||
{{ $t('product.product-modbus.562372-326') }}
|
||||
</el-button>
|
||||
</template>
|
||||
<template slot-scope="scope">
|
||||
<el-button v-if="enableEditIO" size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row, scope.$index, configList, 'isSelectIo')" v-hasPermi="['iot:config:remove']">
|
||||
{{ $t('product.product-modbus.562372-35') }}
|
||||
@ -180,14 +194,15 @@
|
||||
<el-button type="primary" plain icon="el-icon-upload" v-if="productInfo.status == 1" size="mini" @click.stop="batchImport('isSelectData')" v-hasPermi="['modbus:config:import']">
|
||||
{{ $t('product.product-modbus.562372-40') }}
|
||||
</el-button>
|
||||
<el-button plain icon="el-icon-download" v-if="productInfo.status == 1" size="mini" @click.stop="exportModbus('isSelectIo')" v-hasPermi="['modbus:config:export']">
|
||||
<el-button plain icon="el-icon-download" v-if="productInfo.status == 1" size="mini" @click.stop="exportModbus('isSelectData')" v-hasPermi="['modbus:config:export']">
|
||||
{{ $t('product.product-modbus.562372-41') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table style="margin-bottom: 10px" v-loading="loadingData" :data="dataModbusList" :key="dataTableKey" ref="Dataable">
|
||||
<el-table style="margin-bottom: 10px" v-loading="loadingData" :data="dataModbusList" :key="dataTableKey" ref="Dataable" @selection-change="handleDataSelectionChange">
|
||||
<el-table-column type="selection" width="45" align="center" :selectable="dataSelectable" />
|
||||
<el-table-column :label="$t('product.product-modbus.562372-24')" align="center" prop="sort"></el-table-column>
|
||||
<el-table-column :label="$t('product.product-modbus.562372-25')" align="center" prop="identifier" width="250px">
|
||||
<template slot-scope="scope">
|
||||
@ -197,14 +212,12 @@
|
||||
v-model="scope.row.identifier"
|
||||
:placeholder="$t('product.product-modbus.562372-26')"
|
||||
size="mini"
|
||||
:ref="`selectData${scope.$index}`"
|
||||
@change="(e) => updateSelectThingsModel({ newVal: e, oldVal: $refs[`selectData${scope.$index}`].value, justiceSelect: 'isSelectData' })"
|
||||
>
|
||||
<el-option key="0" label="" value="" disabled style="width: 300px">
|
||||
<span style="float: left">{{ $t('product.product-modbus.562372-27') }}</span>
|
||||
<span style="float: right; color: #8492a6; font-size: 13px">{{ $t('product.product-modbus.562372-28') }}</span>
|
||||
</el-option>
|
||||
<el-option v-for="item in thingsModelList" :key="item.identifier" :label="`${item.modelName} (${item.identifier})`" :value="item.identifier" style="width: 300px" :disabled="!item.isSelectData">
|
||||
<el-option v-for="item in thingsModelList" :key="item.identifier" :label="`${item.modelName} (${item.identifier})`" :value="item.identifier" style="width: 300px" :disabled="isOptionDisabled(item, 'isSelectData')">
|
||||
<!-- 判断是否可选 -->
|
||||
<span style="float: left">{{ item.modelName }}</span>
|
||||
<span style="float: right; color: #8492a6; font-size: 13px">{{ item.identifier }}</span>
|
||||
@ -212,26 +225,37 @@
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('product.product-modbus.562372-29')" align="center" prop="identifier" width="170px">
|
||||
<el-table-column :label="$t('product.product-modbus.562372-29')" align="center" prop="slave" width="170px">
|
||||
<template slot="header">
|
||||
<modbus-column-batch :label="$t('product.product-modbus.562372-29')" column="slave" :disabled="!enableEditData" :scope-rows="dataScopeRows" :scope-mode="dataScopeMode" :disabled-tip="editDisabledTip" @apply="(p) => onColumnApply('data', p)" />
|
||||
</template>
|
||||
<template slot-scope="scope">
|
||||
<el-input-number :disabled="!enableEditData" v-model="scope.row.slave" size="small" :min="1" :max="256" :label="$t('product.product-modbus.562372-29')"></el-input-number>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('product.product-modbus.562372-30')" align="center" prop="address" width="170px">
|
||||
<template slot="header">
|
||||
<modbus-column-batch :label="$t('product.product-modbus.562372-30')" column="addressAuto" :disabled="!enableEditData" :scope-rows="dataScopeRows" :scope-mode="dataScopeMode" :disabled-tip="editDisabledTip" @apply="(p) => onColumnApply('data', p)" />
|
||||
</template>
|
||||
<template slot-scope="scope">
|
||||
<el-input-number :disabled="!enableEditData" v-model="scope.row.address" size="small" :min="0" :max="400000" :label="$t('product.product-modbus.562372-30')"></el-input-number>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('product.product-modbus.562372-31')" align="center" prop="isReadonly" width="170px">
|
||||
<template slot="header">
|
||||
<modbus-column-batch :label="$t('product.product-modbus.562372-31')" column="isReadonly" :disabled="!enableEditData" :scope-rows="dataScopeRows" :scope-mode="dataScopeMode" :disabled-tip="editDisabledTip" @apply="(p) => onColumnApply('data', p)" />
|
||||
</template>
|
||||
<template slot-scope="scope">
|
||||
<el-radio-group :disabled="!enableEditData" size="mini" v-model="scope.row.isReadonly">
|
||||
<el-radio-button label="0">{{ $t('product.product-modbus.562372-32') }}</el-radio-button>
|
||||
<el-radio-button label="1">{{ $t('product.product-modbus.562372-33') }}</el-radio-button>
|
||||
<el-radio-button :label="0">{{ $t('product.product-modbus.562372-32') }}</el-radio-button>
|
||||
<el-radio-button :label="1">{{ $t('product.product-modbus.562372-33') }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
<!-- <el-tag v-show="!enableEditData">{{scope.row.isReadonly=='0'?'只读':'读写'}}</el-tag> -->
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('product.product-modbus.562372-42')" align="center" prop="dataType" width="200px">
|
||||
<template slot="header">
|
||||
<modbus-column-batch :label="$t('product.product-modbus.562372-42')" column="dataType" :disabled="!enableEditData" :scope-rows="dataScopeRows" :scope-mode="dataScopeMode" :disabled-tip="editDisabledTip" @apply="(p) => onColumnApply('data', p)" />
|
||||
</template>
|
||||
<template slot-scope="scope">
|
||||
<el-select :disabled="!enableEditData" size="mini" v-model="scope.row.dataType" :placeholder="$t('product.product-modbus.562372-42')" style="display: inline-block; padding-right: 10px">
|
||||
<el-option v-for="dict in dict.type.iot_modbus_data_type" :key="dict.value" :label="dict.label" :value="dict.value"></el-option>
|
||||
@ -239,11 +263,20 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('product.product-modbus.562372-43')" align="center" prop="quantity" width="170px">
|
||||
<template slot="header">
|
||||
<modbus-column-batch :label="$t('product.product-modbus.562372-43')" column="quantity" :disabled="!enableEditData" :scope-rows="dataScopeRows" :scope-mode="dataScopeMode" :disabled-tip="editDisabledTip" @apply="(p) => onColumnApply('data', p)" />
|
||||
</template>
|
||||
<template slot-scope="scope">
|
||||
<el-input-number :disabled="!enableEditData" v-model="scope.row.quantity" size="small" :min="1" :max="256" :label="$t('product.product-modbus.562372-43')"></el-input-number>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('product.product-modbus.562372-34')" align="center" class-name="small-padding fixed-width">
|
||||
<template slot="header">
|
||||
<span>{{ $t('product.product-modbus.562372-34') }}</span>
|
||||
<el-button type="text" size="mini" icon="el-icon-delete" :disabled="!enableEditData" @click="handleBatchDelete('data')" v-hasPermi="['iot:config:remove']">
|
||||
{{ $t('product.product-modbus.562372-326') }}
|
||||
</el-button>
|
||||
</template>
|
||||
<template slot-scope="scope">
|
||||
<el-button v-if="enableEditData" size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row, scope.$index, dataModbusList, 'isSelectData')" v-hasPermi="['iot:config:remove']">
|
||||
{{ $t('product.product-modbus.562372-35') }}
|
||||
@ -264,7 +297,7 @@
|
||||
</el-row>
|
||||
|
||||
<!-- 选择物模型 -->
|
||||
<things-list ref="thingsListRef" :productId="productId" @productEvent="getThingsData($event)" :justiceSelect="justiceSelect" />
|
||||
<things-list ref="thingsListRef" :productId="productId" @productEvent="getThingsData($event)" :justiceSelect="justiceSelect" :judge-disabled="isOptionDisabled" />
|
||||
<!-- 批量导入 -->
|
||||
<import-batch ref="importBatchRef" :productId="productId" @data-imported="handleDataImport" :justiceSelect="justiceSelect" />
|
||||
</div>
|
||||
@ -275,12 +308,14 @@ import { listConfig, addBatch } from '@/api/iot/modbusConfig';
|
||||
import { getlListModbus } from '@/api/iot/model';
|
||||
import thingsList from './things-model-list';
|
||||
import importBatch from './components/batchImportModbus';
|
||||
import modbusColumnBatch from './components/modbusColumnBatch';
|
||||
import { deriveQuantity, resolveQuantity } from './modbus-derive';
|
||||
import Sortable from 'sortablejs';
|
||||
import { addOrUpdate, getByProductId } from '@/api/iot/params';
|
||||
import { listProductJob } from '@/api/iot/modbusJob';
|
||||
|
||||
export default {
|
||||
name: 'product-modbus-copy',
|
||||
name: 'product-modbus',
|
||||
dicts: ['iot_modbus_data_type', 'iot_yes_no', 'data_collect_type', 'device_status_deter', 'iot_modbus_poll_time'],
|
||||
props: {
|
||||
product: {
|
||||
@ -291,16 +326,16 @@ export default {
|
||||
components: {
|
||||
thingsList,
|
||||
importBatch,
|
||||
modbusColumnBatch,
|
||||
},
|
||||
watch: {
|
||||
// 获取到父组件传递的productId后,刷新列表
|
||||
product: function (newVal, oldVal) {
|
||||
this.productInfo = newVal;
|
||||
if (this.productInfo && this.productInfo.productId != 0) {
|
||||
this.thingsModelParams.productId = this.productInfo.productId;
|
||||
this.queryParamsIO.productId = this.productInfo.productId;
|
||||
this.queryParamsData.productId = this.productInfo.productId;
|
||||
this.productId = this.productInfo.productId;
|
||||
if (newVal && newVal.productId != 0) {
|
||||
this.thingsModelParams.productId = newVal.productId;
|
||||
this.queryParamsIO.productId = newVal.productId;
|
||||
this.queryParamsData.productId = newVal.productId;
|
||||
this.productId = newVal.productId;
|
||||
this.getIOList();
|
||||
this.getDataList();
|
||||
this.getThingsModelList();
|
||||
@ -311,25 +346,26 @@ export default {
|
||||
enableEditIO: function (n, old) {
|
||||
//设置行是否拖拽
|
||||
if (this.sortableIo) this.sortableIo.option('disabled', !n);
|
||||
//关闭编辑刷新页面
|
||||
//关闭编辑刷新页面(保存与取消都会走到这里:重拉数据与物模型可选标记,并清空本次编辑的临时状态)
|
||||
if (!n) {
|
||||
this.getIOList();
|
||||
this.getThingsModelList();
|
||||
}
|
||||
//清空删除数组
|
||||
this.ioSelection = [];
|
||||
this.releasedIo = [];
|
||||
this.delIoIds = [];
|
||||
this.getThingsModelList();
|
||||
}
|
||||
},
|
||||
enableEditData: function (n, old) {
|
||||
//设置行是否拖拽
|
||||
if (this.sortableData) this.sortableData.option('disabled', !n);
|
||||
//关闭编辑刷新页面
|
||||
//关闭编辑刷新页面(保存与取消都会走到这里:重拉数据与物模型可选标记,并清空本次编辑的临时状态)
|
||||
if (!n) {
|
||||
this.getDataList();
|
||||
this.getThingsModelList();
|
||||
}
|
||||
//清空删除数组
|
||||
this.dataSelection = [];
|
||||
this.releasedData = [];
|
||||
this.delDataIds = [];
|
||||
}
|
||||
},
|
||||
},
|
||||
data() {
|
||||
@ -337,14 +373,6 @@ export default {
|
||||
// 遮罩层
|
||||
loadingIO: true,
|
||||
loadingData: true,
|
||||
// 选中数组
|
||||
ids: [],
|
||||
// 非单个禁用
|
||||
single: true,
|
||||
// 非多个禁用
|
||||
multiple: true,
|
||||
// 显示搜索条件
|
||||
showSearch: true,
|
||||
// 总条数
|
||||
total: 0,
|
||||
// modbus配置表格数据
|
||||
@ -353,10 +381,6 @@ export default {
|
||||
enableEditIO: false,
|
||||
//子设备配置是否可编辑
|
||||
enableSetSlave: false,
|
||||
// 弹出层标题
|
||||
title: '',
|
||||
// 是否显示弹出层
|
||||
open: false,
|
||||
// 查询参数
|
||||
queryParamsIO: {
|
||||
pageNum: 1,
|
||||
@ -392,10 +416,16 @@ export default {
|
||||
// 数据寄存器遮罩层
|
||||
dataLoading: true,
|
||||
|
||||
//IO表勾选行(列头批量作用域:有勾选=勾选行,否则本页全部行)
|
||||
ioSelection: [],
|
||||
//数据表勾选行
|
||||
dataSelection: [],
|
||||
//本次编辑中被删除行占用的物模型标识符(后端标记要保存后才变,期间放行其重新可选)
|
||||
releasedIo: [],
|
||||
releasedData: [],
|
||||
|
||||
//可选属性
|
||||
thingsModelList: [],
|
||||
//物模型遮罩层
|
||||
thingsLoading: true,
|
||||
//thingsModel
|
||||
thingsModelParams: {
|
||||
pageNum: 1,
|
||||
@ -403,7 +433,6 @@ export default {
|
||||
productId: 0,
|
||||
isCalculated: 0,
|
||||
},
|
||||
thingsTotal: 0,
|
||||
// 表单参数
|
||||
form: {
|
||||
statusDeter: 1,
|
||||
@ -411,15 +440,9 @@ export default {
|
||||
pollType: 0,
|
||||
deterTimer: '300',
|
||||
},
|
||||
productInfo: {},
|
||||
// 表单校验
|
||||
rules: {
|
||||
slaveId: [{ required: true, message: this.$t('product.product-modbus.562372-46'), trigger: 'blur' }],
|
||||
address: [{ required: true, message: this.$t('product.product-modbus.562372-47'), trigger: 'blur' }],
|
||||
isReadonly: [{ required: true, message: this.$t('product.product-modbus.562372-48'), trigger: 'blur' }],
|
||||
dataType: [{ required: true, message: this.$t('product.product-modbus.562372-49'), trigger: 'change' }],
|
||||
quantity: [{ required: true, message: this.$t('product.product-modbus.562372-50'), trigger: 'blur' }],
|
||||
type: [{ required: true, message: this.$t('product.product-modbus.562372-51'), trigger: 'change' }],
|
||||
},
|
||||
//IO行拖拽对象
|
||||
sortableIo: null,
|
||||
@ -438,6 +461,35 @@ export default {
|
||||
jobTotal: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
//直接读 prop,不再维护副本(副本只在 watch 触发时同步,一旦脱节:
|
||||
//productInfo 停留为 {} → status==1 判定失败 → 「编辑寄存器」按钮不渲染,而批量入口仍可见,用户只会得到"请先点击编辑寄存器"的无效提示)
|
||||
productInfo() {
|
||||
return this.product || {};
|
||||
},
|
||||
//批量入口禁用原因(提示要指向用户当下真正该做的动作)
|
||||
editDisabledTip() {
|
||||
if (this.productInfo.status != 1) return this.$t('product.product-modbus.562372-338');
|
||||
return this.$t('product.product-modbus.562372-331');
|
||||
},
|
||||
//列头批量作用域:有勾选行用勾选行,否则本页全部行(不再问第二遍)
|
||||
//一律按表格行序返回:el-table 的 selection 是"勾选先后"顺序,地址生成必须按行序
|
||||
ioScopeRows() {
|
||||
const rows = this.ioSelection.length ? this.ioSelection : this.configList;
|
||||
return this.sortByTableOrder(rows, this.configList);
|
||||
},
|
||||
dataScopeRows() {
|
||||
const rows = this.dataSelection.length ? this.dataSelection : this.dataModbusList;
|
||||
return this.sortByTableOrder(rows, this.dataModbusList);
|
||||
},
|
||||
//作用域来源(仅用于面板文案:作用:本页 N 行 / 已勾选 N 行)
|
||||
ioScopeMode() {
|
||||
return this.ioSelection.length ? 'selected' : 'all';
|
||||
},
|
||||
dataScopeMode() {
|
||||
return this.dataSelection.length ? 'selected' : 'all';
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.rowDropIo();
|
||||
this.rowDropData();
|
||||
@ -475,35 +527,143 @@ export default {
|
||||
},
|
||||
/** 查询产品物模型列表 */
|
||||
getThingsModelList() {
|
||||
this.thingsLoading = true;
|
||||
getlListModbus(this.thingsModelParams).then((response) => {
|
||||
this.$refs.thingsListRef.modelList = response.rows;
|
||||
this.thingsModelList = response.rows;
|
||||
this.thingsTotal = response.total;
|
||||
this.thingsLoading = false;
|
||||
});
|
||||
},
|
||||
// 取消按钮
|
||||
cancel() {
|
||||
this.open = false;
|
||||
this.reset();
|
||||
// IO表勾选(仅编辑态可勾选)
|
||||
ioSelectable() {
|
||||
return this.enableEditIO;
|
||||
},
|
||||
// 表单重置
|
||||
reset() {
|
||||
this.form = {
|
||||
statusDeter: 1,
|
||||
slaveId: null,
|
||||
// pollType: '0',
|
||||
deterTime: '300',
|
||||
};
|
||||
this.resetForm('form');
|
||||
// 数据表勾选(仅编辑态可勾选)
|
||||
dataSelectable() {
|
||||
return this.enableEditData;
|
||||
},
|
||||
handleIoSelectionChange(selection) {
|
||||
this.ioSelection = selection;
|
||||
},
|
||||
handleDataSelectionChange(selection) {
|
||||
this.dataSelection = selection;
|
||||
},
|
||||
// 按表格行序重排(传入的已是整个列表时直接返回,不产生新数组)
|
||||
sortByTableOrder(rows, list) {
|
||||
if (rows === list) return rows;
|
||||
return rows.slice().sort((a, b) => list.indexOf(a) - list.indexOf(b));
|
||||
},
|
||||
|
||||
// 多选框选中数据
|
||||
handleSelectionChange(selection) {
|
||||
this.ids = selection.map((item) => item.id);
|
||||
this.single = selection.length !== 1;
|
||||
this.multiple = !selection.length;
|
||||
/**
|
||||
* 寄存器地址一键生成:从起始地址开始,按各行占用的寄存器个数(读取个数)依次推进。
|
||||
* 步长口径 = modbus-derive#resolveQuantity:dataType 非空以派生值为准,dataType 为空的历史行回退行内 quantity,再缺省 1。
|
||||
* 仅数据寄存器使用(IO 寄存器同地址按 bitOrder 取位,地址不递增)。
|
||||
*/
|
||||
applyAddressAuto(table, rows, startAddress) {
|
||||
if (!rows.length) return;
|
||||
const list = table === 'io' ? this.configList : this.dataModbusList;
|
||||
const from = Number(startAddress);
|
||||
let cursor = from;
|
||||
let last = from;
|
||||
rows.forEach((row) => {
|
||||
row.address = cursor;
|
||||
last = cursor;
|
||||
cursor += resolveQuantity(row);
|
||||
});
|
||||
this.$modal.msgSuccess(this.$t('product.product-modbus.562372-336', { n: rows.length, from, to: last }));
|
||||
// 冲突检测:同从机地址 + 同寄存器地址会被 ModbusProtocol 分组解析互相覆盖,本页内先提示
|
||||
const seen = Object.create(null);
|
||||
let duplicate = 0;
|
||||
list.forEach((row) => {
|
||||
const key = `${row.slave}-${row.address}`;
|
||||
if (seen[key]) duplicate++;
|
||||
else seen[key] = true;
|
||||
});
|
||||
if (duplicate > 0) {
|
||||
this.$modal.msgWarning(this.$t('product.product-modbus.562372-337', { m: duplicate }));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 物模型「可否选中」派生判定(全表统一口径,不再增量改写 thingsModelList):
|
||||
* ① 本次编辑刚删除释放的标识符放行(后端标记要保存后才变)
|
||||
* ② 当前表内存中已占用的标识符禁用(覆盖未保存的新增行)
|
||||
* ③ 后端回传的 isSelectIo/isSelectData 兜底
|
||||
*/
|
||||
isOptionDisabled(item, justiceSelect) {
|
||||
const released = justiceSelect === 'isSelectIo' ? this.releasedIo : this.releasedData;
|
||||
if (released.indexOf(item.identifier) !== -1) return false;
|
||||
const list = justiceSelect === 'isSelectIo' ? this.configList : this.dataModbusList;
|
||||
if (list.some((row) => row.identifier && row.identifier === item.identifier)) return true;
|
||||
return !item[justiceSelect];
|
||||
},
|
||||
|
||||
/**
|
||||
* 列头批量应用:一列一个入口,谁的值谁负责。
|
||||
* - dataType:写入后联动 quantity = deriveQuantity(dataType)(字典每种类型位宽唯一)
|
||||
* - quantity:仅允许 1/2;dataType 非空且派生值不符的行跳过(避免写出 ModbusProtocol 会丢弃的矛盾行)
|
||||
* - addressAuto:一键生成地址序列(起始地址 + 按各行读取个数递增)
|
||||
* - 其余列直接写
|
||||
*/
|
||||
onColumnApply(table, { column, value }) {
|
||||
const rows = table === 'io' ? this.ioScopeRows : this.dataScopeRows;
|
||||
if (column === 'addressAuto') {
|
||||
this.applyAddressAuto(table, rows, value);
|
||||
return;
|
||||
}
|
||||
let changed = 0;
|
||||
let skipped = 0;
|
||||
rows.forEach((row) => {
|
||||
if (column === 'dataType') {
|
||||
row.dataType = value;
|
||||
const quantity = deriveQuantity(value);
|
||||
if (quantity !== null) row.quantity = quantity;
|
||||
changed++;
|
||||
} else if (column === 'quantity') {
|
||||
if (row.dataType && deriveQuantity(row.dataType) !== value) {
|
||||
skipped++;
|
||||
return;
|
||||
}
|
||||
row.quantity = value;
|
||||
changed++;
|
||||
} else {
|
||||
row[column] = value;
|
||||
changed++;
|
||||
}
|
||||
});
|
||||
if (changed > 0) {
|
||||
this.$modal.msgSuccess(this.$t('product.product-modbus.562372-323', { n: changed }));
|
||||
}
|
||||
if (skipped > 0) {
|
||||
this.$modal.msgWarning(this.$t('product.product-modbus.562372-325', { m: skipped }));
|
||||
}
|
||||
},
|
||||
|
||||
/** 操作列头批量删除:必须先勾选行(删除高危,不做"本页全部"默认),本地删除、保存驱动提交 */
|
||||
handleBatchDelete(table) {
|
||||
const selection = table === 'io' ? this.ioSelection : this.dataSelection;
|
||||
if (!selection.length) {
|
||||
this.$modal.msgWarning(this.$t('product.product-modbus.562372-327'));
|
||||
return;
|
||||
}
|
||||
const savedCount = selection.filter((row) => row.id).length;
|
||||
this.$confirm(this.$t('product.product-modbus.562372-328', { n: selection.length, m: savedCount }), this.$t('product.product-modbus.562372-313'), {
|
||||
confirmButtonText: this.$t('product.product-modbus.562372-314'),
|
||||
cancelButtonText: this.$t('product.product-modbus.562372-315'),
|
||||
type: 'warning',
|
||||
})
|
||||
.then(() => {
|
||||
const list = table === 'io' ? this.configList : this.dataModbusList;
|
||||
const delIds = table === 'io' ? this.delIoIds : this.delDataIds;
|
||||
const released = table === 'io' ? this.releasedIo : this.releasedData;
|
||||
selection.forEach((row) => {
|
||||
const index = list.indexOf(row);
|
||||
if (index !== -1) list.splice(index, 1);
|
||||
if (row.id) delIds.push(row.id);
|
||||
if (row.identifier) released.push(row.identifier);
|
||||
});
|
||||
if (table === 'io') this.ioSelection = [];
|
||||
else this.dataSelection = [];
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
/** 新增IO寄存器行操作 */
|
||||
handleAdd() {
|
||||
@ -591,6 +751,11 @@ export default {
|
||||
const dataModbusList = [];
|
||||
this.dataModbusList.forEach((dataModbus, index) => {
|
||||
if (dataModbus.identifier) {
|
||||
//读取个数是数据类型的派生量:提交前回算,但仅当 dataType 非空(历史行 data_type 可空,勿改坏)
|
||||
if (dataModbus.dataType) {
|
||||
const quantity = deriveQuantity(dataModbus.dataType);
|
||||
if (quantity !== null) dataModbus.quantity = quantity;
|
||||
}
|
||||
dataModbus.sort = dataModbusList.length + 1;
|
||||
dataModbusList.push(dataModbus);
|
||||
}
|
||||
@ -608,15 +773,13 @@ export default {
|
||||
this.loadingData = false;
|
||||
});
|
||||
},
|
||||
/** 删除按钮操作 */
|
||||
/** 删除按钮操作:本地删除 + 收集已保存行 id;释放的物模型占用由 isOptionDisabled 派生判定自动生效 */
|
||||
handleDelete(row, index, list, justiceSelect) {
|
||||
const item = list.splice(index, 1)[0];
|
||||
//如果是删除数据寄存器,并且有id就存id
|
||||
if (justiceSelect == 'isSelectData' && row.id) this.delDataIds.push(row.id);
|
||||
//如果是删除io,并且有id就存id
|
||||
if (justiceSelect == 'isSelectIo' && row.id) this.delIoIds.push(row.id);
|
||||
//处理选项是否可选
|
||||
this.updateSelectThingsModel({ justiceSelect, oldVal: item.identifier });
|
||||
list.splice(index, 1);
|
||||
const delIds = justiceSelect === 'isSelectIo' ? this.delIoIds : this.delDataIds;
|
||||
const released = justiceSelect === 'isSelectIo' ? this.releasedIo : this.releasedData;
|
||||
if (row.id) delIds.push(row.id);
|
||||
if (row.identifier) released.push(row.identifier);
|
||||
},
|
||||
|
||||
/*获取选择的物模型*/
|
||||
@ -635,7 +798,6 @@ export default {
|
||||
type: this.justiceSelect == 'isSelectData' ? 2 : 1,
|
||||
sort: list.length + 1,
|
||||
});
|
||||
this.updateSelectThingsModel({ justiceSelect: this.justiceSelect, newVal: id });
|
||||
}
|
||||
});
|
||||
},
|
||||
@ -684,16 +846,6 @@ export default {
|
||||
//将选择中那列查到新位置
|
||||
list.splice(newIndex, 0, curRow);
|
||||
},
|
||||
/**
|
||||
* 更新物模型是否可选
|
||||
*/
|
||||
updateSelectThingsModel({ oldVal, newVal, justiceSelect }) {
|
||||
//直接改前后值
|
||||
const oldIndex = oldVal ? this.thingsModelList.findIndex((thingsModel) => thingsModel.identifier == oldVal) : -1;
|
||||
const newIndex = newVal ? this.thingsModelList.findIndex((thingsModel) => thingsModel.identifier == newVal) : -1;
|
||||
if (oldIndex != -1) this.thingsModelList[oldIndex][justiceSelect] = true;
|
||||
if (newIndex != -1) this.thingsModelList[newIndex][justiceSelect] = false;
|
||||
},
|
||||
/**批量导入 */
|
||||
batchImport(justiceSelect) {
|
||||
this.justiceSelect = justiceSelect;
|
||||
@ -702,7 +854,7 @@ export default {
|
||||
/**导出 */
|
||||
exportModbus(justiceSelect) {
|
||||
const type = justiceSelect == 'isSelectData' ? 2 : 1;
|
||||
const name = justiceSelect == 'isSelectData' ? this.$t('product.product-modbus.562372-17') : this.$t('product.product-modbus.562372-38');
|
||||
const name = justiceSelect == 'isSelectData' ? this.$t('product.product-modbus.562372-38') : this.$t('product.product-modbus.562372-17');
|
||||
this.download('/modbus/config/exportModbus?type=' + type, {}, `${name}_${new Date().getTime()}.xlsx`);
|
||||
},
|
||||
/**获取modbus参数配置*/
|
||||
@ -760,16 +912,13 @@ export default {
|
||||
},
|
||||
/** 查询轮训任务列列表 */
|
||||
getTaskList(productId) {
|
||||
this.loading = true;
|
||||
const params = {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
productId: productId,
|
||||
};
|
||||
listProductJob(params).then((response) => {
|
||||
this.jobList = response.rows;
|
||||
this.jobTotal = response.total;
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
/**取消*/
|
||||
|
||||
@ -46,6 +46,11 @@ export default {
|
||||
justiceSelect: {
|
||||
type: String,
|
||||
default: 'isSelectData'
|
||||
},
|
||||
// 父组件的「可否选中」派生判定(表内内存占用 + 刚删除释放 + 后端标记),与本页下拉同口径
|
||||
judgeDisabled: {
|
||||
type: Function,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@ -92,10 +97,7 @@ export default {
|
||||
this.selectedList.forEach(selected => {
|
||||
const findIndex = this.modelList.findIndex(model => model.identifier == selected.identifier)
|
||||
if (findIndex != -1) {
|
||||
const model = this.modelList[findIndex]
|
||||
model.isSelectData = false
|
||||
model.isSelectIo = false
|
||||
this.$refs.thingsModelTable.toggleRowSelection(model, true)
|
||||
this.$refs.thingsModelTable.toggleRowSelection(this.modelList[findIndex], true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -132,8 +134,11 @@ export default {
|
||||
closeDialog() {
|
||||
this.open = false;
|
||||
},
|
||||
//是否可选
|
||||
//是否可选:优先走父组件派生判定(内存占用/刚删除释放/后端标记三合一),无判定时退回后端标记
|
||||
selectable(item) {
|
||||
if (this.judgeDisabled) {
|
||||
return !this.judgeDisabled(item, this.justiceSelect)
|
||||
}
|
||||
return item[this.justiceSelect]
|
||||
}
|
||||
}
|
||||
|
||||
@ -180,6 +180,7 @@ import { getCodeImg, checkBindId, getErrorMsg, socialLogin, bindLogin, getSmsLog
|
||||
import Cookies from 'js-cookie';
|
||||
import { encrypt, decrypt } from '@/utils/jsencrypt';
|
||||
import { setToken } from '@/utils/auth';
|
||||
import { getUserSetting } from '@/api/system/userSetting';
|
||||
import langSelect from '@/layout/components/langSelect';
|
||||
|
||||
export default {
|
||||
@ -312,6 +313,34 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* 解析登录后的跳转目标
|
||||
* 优先级:URL 携带 redirect(分享/未授权跳转) > 个人设置 > 默认大屏
|
||||
* 任何分支失败都退到 /bigscreen,不阻断登录
|
||||
*
|
||||
* 所有登录入口(账号密码 / 短信 / 绑定 / 第三方)都必须走这里,
|
||||
* 否则各写一份硬编码 '/bigscreen' 会让「登录后显示页面」只对其中一种登录方式生效。
|
||||
*/
|
||||
resolveLoginRedirect() {
|
||||
if (this.redirect) {
|
||||
return Promise.resolve(this.redirect);
|
||||
}
|
||||
return getUserSetting()
|
||||
.then((res) => {
|
||||
const data = res.data || {};
|
||||
return data.loginRedirect || '/bigscreen';
|
||||
})
|
||||
.catch(() => '/bigscreen');
|
||||
},
|
||||
/**
|
||||
* 按登录后落地页跳转
|
||||
* 独立成方法,避免各登录入口重复写 router.push 与 catch
|
||||
*/
|
||||
redirectAfterLogin() {
|
||||
this.resolveLoginRedirect().then((path) => {
|
||||
this.$router.push({ path }).catch(() => {});
|
||||
});
|
||||
},
|
||||
changesmsForm() {
|
||||
this.acountLogin = !this.acountLogin;
|
||||
this.phoneLogin = !this.phoneLogin;
|
||||
@ -360,11 +389,7 @@ export default {
|
||||
// 保存token
|
||||
this.loading = true;
|
||||
setToken(res.token);
|
||||
this.$router
|
||||
.push({
|
||||
path: '/bigscreen',
|
||||
})
|
||||
.catch(() => {});
|
||||
this.redirectAfterLogin();
|
||||
if (this.captchaOnOff) {
|
||||
this.getCode();
|
||||
this.loading = false;
|
||||
@ -482,11 +507,7 @@ export default {
|
||||
this.smsLoading = true;
|
||||
smsLogin(this.smsLoginForm).then((res) => {
|
||||
setToken(res.data);
|
||||
this.$router
|
||||
.push({
|
||||
path: '/bigscreen',
|
||||
})
|
||||
.catch(() => {});
|
||||
this.redirectAfterLogin();
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -515,11 +536,7 @@ export default {
|
||||
.then((res) => {
|
||||
// 保存token
|
||||
setToken(res.token);
|
||||
this.$router
|
||||
.push({
|
||||
path: '/bigscreen',
|
||||
})
|
||||
.catch(() => {});
|
||||
this.redirectAfterLogin();
|
||||
})
|
||||
.catch(() => {
|
||||
this.loading = false;
|
||||
@ -570,11 +587,8 @@ export default {
|
||||
this.$store
|
||||
.dispatch('Login', this.loginForm)
|
||||
.then(() => {
|
||||
this.$router
|
||||
.push({
|
||||
path: '/bigscreen',
|
||||
})
|
||||
.catch(() => {});
|
||||
// 跳转优先级:URL 携带的 redirect(如会话过期前所在页) > 个人设置 > 默认大屏
|
||||
this.redirectAfterLogin();
|
||||
})
|
||||
.catch(() => {
|
||||
this.loading = false;
|
||||
|
||||
@ -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,
|
||||
|
||||
75
yack-web/src/views/system/setting/components/SettingRow.vue
Normal file
75
yack-web/src/views/system/setting/components/SettingRow.vue
Normal file
@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">{{ label }}</div>
|
||||
<div class="setting-value">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="desc" class="setting-desc">{{ desc }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 配置清单的一行:左「配置名」、中「当前值/控件」、右「参数说明」三段同行
|
||||
*
|
||||
* 「系统设置」与「全局配置」共用本组件,保证同一页面里每条配置都严格占一行。
|
||||
* 标签列是**固定宽度且不折行**的:配置名一旦折行,一条配置在视觉上会被读成两条
|
||||
* (`下发读取IMEI/ICCID` 被 120px 挤成两行就是这个原因)。
|
||||
*
|
||||
* 新增配置只需再写一个 <setting-row>,排版由本组件统一保证。
|
||||
*/
|
||||
export default {
|
||||
name: 'SettingRow',
|
||||
props: {
|
||||
// 配置名。超长时省略号截断而不是折行,守住「一行一条」
|
||||
label: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
// 参数说明(参数键、取值含义等)。为空时整列不占位
|
||||
desc: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 标签列宽与列间距:外部若要把动作按钮对齐到控件列下方,需按这两个值缩进,见 UserSettingForm.vue
|
||||
$setting-label-width: 160px;
|
||||
$setting-label-gap: 12px;
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
|
||||
.setting-label {
|
||||
flex: 0 0 $setting-label-width;
|
||||
padding-right: $setting-label-gap;
|
||||
overflow: hidden;
|
||||
text-align: right;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// 控件列定宽:同一清单里各行的说明列因此从左对齐的同一条竖线开始
|
||||
.setting-value {
|
||||
flex: 0 0 300px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.setting-desc {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding-left: $setting-label-gap;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
151
yack-web/src/views/system/setting/components/UserSettingForm.vue
Normal file
151
yack-web/src/views/system/setting/components/UserSettingForm.vue
Normal file
@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div class="user-setting">
|
||||
<setting-row label="登录后显示页面" desc="候选项取自系统菜单,仅对该账号生效,修改后下次登录生效。">
|
||||
<el-select
|
||||
v-model="form.loginRedirect"
|
||||
filterable
|
||||
:loading="optionsLoading"
|
||||
placeholder="请选择登录后进入的页面"
|
||||
class="setting-control"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in options"
|
||||
:key="item.path"
|
||||
:label="item.menuName"
|
||||
:value="item.path"
|
||||
/>
|
||||
</el-select>
|
||||
</setting-row>
|
||||
|
||||
<setting-row label="查询网关子设备" desc="按设备编号查询时生效:输入网关子设备编号会返回其所属网关设备;同时允许查到「网关子设备」类型的产品设备。不按编号查(默认列表)时不生效,列表始终不展示子设备。">
|
||||
<el-switch v-model="form.queryGatewayChild" />
|
||||
</setting-row>
|
||||
|
||||
<setting-row label="设备管理查下级机构" desc="开启后设备管理列表返回本机构及全部下级机构的设备,关闭时仅返回本机构设备。仅对归属了机构的账号生效;该值即设备管理页原先的「显示下级机构数据」勾选项,页面内不再就地切换。">
|
||||
<el-switch v-model="form.showChild" />
|
||||
</setting-row>
|
||||
|
||||
<div class="setting-actions">
|
||||
<el-button type="primary" icon="el-icon-check" :loading="saving" @click="submitForm">保存</el-button>
|
||||
<span class="setting-actions__hint">仅提交改动过的配置项</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
getUserSetting,
|
||||
updateUserSetting,
|
||||
listLoginRedirectOptions
|
||||
} from '@/api/system/userSetting';
|
||||
import SettingRow from './SettingRow';
|
||||
|
||||
// 默认值与后端 UserSettingConstants 保持一致:
|
||||
// 未指定的账号保持原有的数据大屏落地行为;子设备开关与「查下级机构数据」默认关闭
|
||||
const DEFAULT_FORM = {
|
||||
loginRedirect: '/bigscreen',
|
||||
queryGatewayChild: false,
|
||||
showChild: false
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'UserSettingForm',
|
||||
components: { SettingRow },
|
||||
data() {
|
||||
return {
|
||||
saving: false,
|
||||
optionsLoading: false,
|
||||
options: [],
|
||||
form: { ...DEFAULT_FORM },
|
||||
// 服务端当前值的快照:保存时只提交与它不同的字段,未改动的不进 payload
|
||||
original: { ...DEFAULT_FORM }
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.getOptions();
|
||||
this.getSetting();
|
||||
},
|
||||
methods: {
|
||||
getOptions() {
|
||||
this.optionsLoading = true;
|
||||
listLoginRedirectOptions()
|
||||
.then((res) => {
|
||||
this.options = res.data || [];
|
||||
})
|
||||
.finally(() => {
|
||||
this.optionsLoading = false;
|
||||
});
|
||||
},
|
||||
getSetting() {
|
||||
getUserSetting().then((res) => {
|
||||
const data = res.data || {};
|
||||
// 后端对未配置过的用户返回默认值,此处仅在值缺失时兜底。
|
||||
// 两个布尔开关不能用 ||:布尔 false 会被吞掉,导致开关回显错乱。
|
||||
const form = {
|
||||
loginRedirect: data.loginRedirect || DEFAULT_FORM.loginRedirect,
|
||||
queryGatewayChild: data.queryGatewayChild === null || data.queryGatewayChild === undefined
|
||||
? DEFAULT_FORM.queryGatewayChild
|
||||
: data.queryGatewayChild,
|
||||
showChild: data.showChild === null || data.showChild === undefined
|
||||
? DEFAULT_FORM.showChild
|
||||
: data.showChild
|
||||
};
|
||||
this.form = { ...form };
|
||||
// 快照必须与回显值同源(含兜底后的值),否则未改动也会被当成脏数据提交
|
||||
this.original = { ...form };
|
||||
});
|
||||
},
|
||||
/** 只取本次真正改动过的字段,未改动的不进 payload */
|
||||
buildPatch() {
|
||||
const patch = {};
|
||||
Object.keys(this.form).forEach((key) => {
|
||||
if (this.form[key] !== this.original[key]) {
|
||||
patch[key] = this.form[key];
|
||||
}
|
||||
});
|
||||
return patch;
|
||||
},
|
||||
submitForm() {
|
||||
const patch = this.buildPatch();
|
||||
const keys = Object.keys(patch);
|
||||
if (keys.length === 0) {
|
||||
// 不发明知无改动的请求;服务端也会拒绝空 payload
|
||||
this.$modal.msgWarning('没有需要保存的改动');
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
updateUserSetting(patch)
|
||||
.then(() => {
|
||||
// 提交成功后推进快照,保证下一次保存只带新的改动
|
||||
this.original = { ...this.original, ...patch };
|
||||
// 「登录后显示页面」下次登录才生效,其余开关下次进入对应页面即生效
|
||||
this.$modal.msgSuccess(keys.includes('loginRedirect')
|
||||
? '保存成功,登录后显示页面将于下次登录生效'
|
||||
: '保存成功');
|
||||
})
|
||||
.finally(() => {
|
||||
this.saving = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.setting-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// 动作按钮缩进到控件列下方(而不是清单左边缘):
|
||||
// 172px = SettingRow 的标签列 160px + 列间距 12px,见 SettingRow.vue
|
||||
.setting-actions {
|
||||
padding: 16px 0 0 172px;
|
||||
|
||||
&__hint {
|
||||
margin-left: 10px;
|
||||
vertical-align: middle;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
60
yack-web/src/views/system/setting/index.vue
Normal file
60
yack-web/src/views/system/setting/index.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-divider content-position="left">系统设置</el-divider>
|
||||
<div class="setting-list">
|
||||
<user-setting-form />
|
||||
</div>
|
||||
|
||||
<el-divider content-position="left">全局配置</el-divider>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="以下配置作用于全部设备与用户,需管理员在「系统管理 → 参数设置」中修改。"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
<div class="setting-list">
|
||||
<setting-row
|
||||
label="下发读取IMEI/ICCID"
|
||||
desc="参数键:iot.power_on.read_imei_iccid —— 设备上电后是否下发 AT+IMEI / AT+ICCID / AT+LBS 指令。"
|
||||
>
|
||||
<el-tag :type="readImeiIccid ? 'success' : 'info'">
|
||||
{{ readImeiIccid ? '已开启' : '已关闭' }}
|
||||
</el-tag>
|
||||
</setting-row>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import UserSettingForm from './components/UserSettingForm';
|
||||
import SettingRow from './components/SettingRow';
|
||||
import { getConfigKey } from '@/api/system/config';
|
||||
|
||||
export default {
|
||||
name: 'UserSetting',
|
||||
components: { UserSettingForm, SettingRow },
|
||||
data() {
|
||||
return {
|
||||
readImeiIccid: true
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.getGlobalSetting();
|
||||
},
|
||||
methods: {
|
||||
getGlobalSetting() {
|
||||
getConfigKey('iot.power_on.read_imei_iccid').then((res) => {
|
||||
this.readImeiIccid = String(res.data) !== 'false';
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 两个清单共用同一宽度,标签列/控件列/说明列在系统设置与全局配置之间保持同一条竖线
|
||||
.setting-list {
|
||||
max-width: 1080px;
|
||||
}
|
||||
</style>
|
||||
@ -18,6 +18,10 @@
|
||||
<svg-icon icon-class="password" class="icon" />
|
||||
<span class="title">修改密码</span>
|
||||
</li>
|
||||
<li :class="{ 'li-wrap': true, 'li-active': tabIndex === 3 }" @click="handleTabClick(3)">
|
||||
<i class="el-icon-setting icon"></i>
|
||||
<span class="title">个人设置</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="right-wrap">
|
||||
@ -39,6 +43,12 @@
|
||||
<resetPwd />
|
||||
</div>
|
||||
</div>
|
||||
<div class="body-wrap" v-show="tabIndex === 3">
|
||||
<div class="header">个人设置</div>
|
||||
<div class="content">
|
||||
<userSettingForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
@ -49,11 +59,12 @@ import userAvatar from './userAvatar';
|
||||
import userInfo from './userInfo';
|
||||
import baseInfo from './baseInfo';
|
||||
import resetPwd from './resetPwd';
|
||||
import userSettingForm from '@/views/system/setting/components/UserSettingForm';
|
||||
import { getUserProfile } from '@/api/system/user';
|
||||
|
||||
export default {
|
||||
name: 'Profile',
|
||||
components: { userAvatar, userInfo, baseInfo, resetPwd },
|
||||
components: { userAvatar, userInfo, baseInfo, resetPwd, userSettingForm },
|
||||
data() {
|
||||
return {
|
||||
tabIndex: 0,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user