系统设置:可以配置化
This commit is contained in:
parent
5d8ae0e2f0
commit
e7ebf3b918
@ -0,0 +1,104 @@
|
||||
package com.fastbee.web.controller.system;
|
||||
|
||||
import com.fastbee.common.annotation.Log;
|
||||
import com.fastbee.common.constant.UserSettingConstants;
|
||||
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.common.utils.StringUtils;
|
||||
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.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 个人系统设置
|
||||
*
|
||||
* <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()));
|
||||
}
|
||||
|
||||
@ApiOperation("保存当前用户固定的任务栏标签")
|
||||
@PutMapping("/pinnedTags")
|
||||
public AjaxResult savePinnedTags(@RequestBody List<String> paths) {
|
||||
userSettingService.savePinnedTags(SecurityUtils.getUserId(), paths, SecurityUtils.getUsername());
|
||||
return AjaxResult.success();
|
||||
}
|
||||
|
||||
@ApiOperation("保存当前用户的设置")
|
||||
@Log(title = "个人设置", businessType = BusinessType.UPDATE)
|
||||
@PutMapping()
|
||||
public AjaxResult save(@RequestBody SysUserSetting setting) {
|
||||
Long userId = SecurityUtils.getUserId();
|
||||
String redirect = setting.getLoginRedirect();
|
||||
if (StringUtils.isEmpty(redirect)) {
|
||||
// 空值不覆盖,交由默认值兜底,避免把用户显式配置冲掉
|
||||
redirect = UserSettingConstants.DEFAULT_LOGIN_REDIRECT;
|
||||
}
|
||||
if (!isValidLoginRedirect(redirect)) {
|
||||
return AjaxResult.error("登录后跳转页面不合法,请从系统菜单中选择");
|
||||
}
|
||||
setting.setLoginRedirect(redirect);
|
||||
// 审计字段由服务端填充,不接受前端传入,避免越权写入他人操作名
|
||||
String username = SecurityUtils.getUsername();
|
||||
setting.setCreateBy(username);
|
||||
setting.setUpdateBy(username);
|
||||
userSettingService.save(userId, setting);
|
||||
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,79 @@
|
||||
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 = "[]";
|
||||
|
||||
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 设备编号
|
||||
|
||||
@ -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,111 @@
|
||||
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;
|
||||
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
@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("createBy", getCreateBy())
|
||||
.append("createTime", getCreateTime())
|
||||
.append("updateBy", getUpdateBy())
|
||||
.append("updateTime", getUpdateTime())
|
||||
.append("remark", getRemark())
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package com.fastbee.system.mapper;
|
||||
|
||||
import com.fastbee.system.domain.LoginRedirectOption;
|
||||
import com.fastbee.system.domain.SysUserSetting;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户系统设置 数据层
|
||||
*
|
||||
* @author Sumuxi
|
||||
*/
|
||||
public interface SysUserSettingMapper {
|
||||
|
||||
/**
|
||||
* 按用户ID查询设置
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 设置,不存在返回 null
|
||||
*/
|
||||
SysUserSetting selectByUserId(Long userId);
|
||||
|
||||
/**
|
||||
* 保存设置(存在则更新,不存在则插入)
|
||||
*
|
||||
* @param setting 设置
|
||||
* @return 影响行数
|
||||
*/
|
||||
int upsert(SysUserSetting setting);
|
||||
|
||||
/**
|
||||
* 仅更新固定标签列
|
||||
*
|
||||
* <p>用户尚无记录时更新 0 行,调用方需自行判断是否改用 insert。
|
||||
*
|
||||
* @param setting 设置,userId 与 pinnedTags 必填
|
||||
* @return 影响行数
|
||||
*/
|
||||
int updatePinnedTags(SysUserSetting setting);
|
||||
|
||||
/**
|
||||
* 查询登录落地页候选项(可作为页签的菜单)
|
||||
*
|
||||
* @return 候选项列表
|
||||
*/
|
||||
List<LoginRedirectOption> selectLoginRedirectOptions();
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
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);
|
||||
|
||||
/**
|
||||
* 保存用户设置(存在则更新,不存在则插入)
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param setting 设置内容,userId 字段忽略,以入参 userId 为准
|
||||
* @return 影响行数
|
||||
*/
|
||||
int save(Long userId, SysUserSetting setting);
|
||||
|
||||
/**
|
||||
* 查询登录落地页候选项
|
||||
*
|
||||
* @return 候选项列表
|
||||
*/
|
||||
List<LoginRedirectOption> listLoginRedirectOptions();
|
||||
|
||||
/**
|
||||
* 保存任务栏固定标签
|
||||
*
|
||||
* <p>只落用户右键固定的 pinned 集合;路由自身声明的 affix 不在其中。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param paths 固定标签的路由 path 列表,按固定顺序
|
||||
* @param operator 操作人用户名,写入 update_by
|
||||
*/
|
||||
void savePinnedTags(Long userId, List<String> paths, String operator);
|
||||
|
||||
/**
|
||||
* 查询任务栏固定标签
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 固定标签的路由 path 列表,按固定顺序;未固定时返回空列表
|
||||
*/
|
||||
List<String> getPinnedTags(Long userId);
|
||||
}
|
||||
@ -0,0 +1,228 @@
|
||||
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 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 数组(元素为带引号的字符串)也不含裸换行。
|
||||
*/
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析缓存值,格式非法时返回 null 走查库
|
||||
*
|
||||
* @param cached 缓存字符串
|
||||
* @param userId 用户ID
|
||||
* @return 设置对象,解析失败返回 null
|
||||
*/
|
||||
private SysUserSetting parseCached(String cached, Long userId) {
|
||||
int idx = cached.indexOf(CACHE_SEPARATOR);
|
||||
if (idx < 0) {
|
||||
return null;
|
||||
}
|
||||
SysUserSetting setting = SysUserSetting.defaultOf(userId);
|
||||
setting.setLoginRedirect(cached.substring(0, idx));
|
||||
setting.setPinnedTags(cached.substring(idx + 1));
|
||||
return setting;
|
||||
}
|
||||
|
||||
@Override
|
||||
@DS("master")
|
||||
public int save(Long userId, SysUserSetting setting) {
|
||||
SysUserSetting target = SysUserSetting.defaultOf(userId);
|
||||
target.setLoginRedirect(setting.getLoginRedirect());
|
||||
target.setRemark(setting.getRemark());
|
||||
Date now = new Date();
|
||||
target.setUpdateBy(setting.getUpdateBy());
|
||||
target.setUpdateTime(now);
|
||||
// create_by/create_time 仅首次写入,由 ON DUPLICATE KEY UPDATE 的列清单保证不被覆盖
|
||||
target.setCreateBy(setting.getUpdateBy());
|
||||
target.setCreateTime(now);
|
||||
|
||||
int rows = userSettingMapper.upsert(target);
|
||||
// 写库成功后立即刷缓存,避免读到旧值。
|
||||
// 回写时保留库中已有的 pinned_tags:upsert 的 ON DUPLICATE 子句不更新该列,
|
||||
// 内存对象却是默认值,若直接回填缓存会把用户的固定标签"洗"成空数组。
|
||||
SysUserSetting latest = userSettingMapper.selectByUserId(userId);
|
||||
writeCache(getCacheKey(userId), latest == null ? target : latest);
|
||||
return rows;
|
||||
}
|
||||
|
||||
@Override
|
||||
@DS("master")
|
||||
public void savePinnedTags(Long userId, List<String> paths, String operator) {
|
||||
String json = normalizePinnedTags(paths);
|
||||
Date now = new Date();
|
||||
|
||||
// 先判断记录是否存在,而不是靠 update 的影响行数:
|
||||
// MySQL 在「值未变化」时 affected_rows 同样为 0,与「记录不存在」无法区分,
|
||||
// 靠返回值判断会在重复提交同一份数据时误走 insert 分支。
|
||||
SysUserSetting exists = userSettingMapper.selectByUserId(userId);
|
||||
SysUserSetting patch = SysUserSetting.defaultOf(userId);
|
||||
patch.setPinnedTags(json);
|
||||
patch.setUpdateBy(operator);
|
||||
patch.setUpdateTime(now);
|
||||
|
||||
if (exists == null) {
|
||||
// 首次固定标签:用户还没有设置记录,走 upsert 而非裸 insert,避免并发下撞唯一键 1062
|
||||
patch.setLoginRedirect(UserSettingConstants.DEFAULT_LOGIN_REDIRECT);
|
||||
patch.setCreateBy(operator);
|
||||
patch.setCreateTime(now);
|
||||
userSettingMapper.upsert(patch);
|
||||
} else {
|
||||
userSettingMapper.updatePinnedTags(patch);
|
||||
}
|
||||
// 回缓存:整体重新查一次,保证与库内最终状态一致
|
||||
SysUserSetting latest = userSettingMapper.selectByUserId(userId);
|
||||
writeCache(getCacheKey(userId), latest == null ? patch : latest);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getPinnedTags(Long userId) {
|
||||
return parsePinnedTags(selectByUserId(userId).getPinnedTags());
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并序列化固定标签列表
|
||||
*
|
||||
* <p>只接受站内绝对路径,去重且保持前端给定的固定顺序。
|
||||
* path 最终会进 router.push,混入外链会变成开放重定向入口。
|
||||
*
|
||||
* @param paths 前端提交的 path 列表
|
||||
* @return JSON 数组字符串
|
||||
*/
|
||||
private String normalizePinnedTags(List<String> paths) {
|
||||
List<String> valid = new ArrayList<>();
|
||||
if (paths != null) {
|
||||
for (String path : paths) {
|
||||
if (StringUtils.isEmpty(path) || !path.startsWith("/")) {
|
||||
continue;
|
||||
}
|
||||
if (path.startsWith("//") || path.contains("://") || path.contains("..")) {
|
||||
continue;
|
||||
}
|
||||
if (!valid.contains(path)) {
|
||||
valid.add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
return JSON.toJSONString(valid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析固定标签,脏数据降级为空列表
|
||||
*
|
||||
* <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,98 @@
|
||||
<?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="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,
|
||||
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>
|
||||
|
||||
<!--
|
||||
user_id 上有唯一索引 uk_sys_user_setting_user,
|
||||
用 INSERT ... ON DUPLICATE KEY UPDATE 收敛「先查后插」的并发竞态:
|
||||
并发首次保存不会因唯一键冲突抛 DuplicateKey,也不需要 SELECT FOR UPDATE。
|
||||
create_time 保留首次写入时间,重复插入时不覆盖。
|
||||
-->
|
||||
<insert id="upsert" parameterType="com.fastbee.system.domain.SysUserSetting">
|
||||
insert into sys_user_setting
|
||||
(user_id, login_redirect, pinned_tags, create_by, create_time, update_by, update_time, remark)
|
||||
values
|
||||
(#{userId}, #{loginRedirect}, #{pinnedTags}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}, #{remark})
|
||||
on duplicate key update
|
||||
login_redirect = values(login_redirect),
|
||||
update_by = values(update_by),
|
||||
update_time = values(update_time),
|
||||
remark = values(remark)
|
||||
</insert>
|
||||
|
||||
<!--
|
||||
单独更新固定标签。固定标签由 tags-view 的 PIN_VIEW / UNPIN_VIEW 高频触发,
|
||||
与登录跳转是两条独立的写入路径:
|
||||
若合并进 upsert,保存登录跳转时会把固定标签一并覆盖成默认值。
|
||||
故只在确实传入 pinnedTags 时才更新该列。
|
||||
-->
|
||||
<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>
|
||||
80
sql/sys_user_setting.sql
Normal file
80
sql/sys_user_setting.sql
Normal file
@ -0,0 +1,80 @@
|
||||
-- =============================================
|
||||
-- 用户系统设置(用户维度)+ 设备侧全局开关入库
|
||||
-- 维度判据:读的时候拿不拿得到 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 天然重建,不入此列。
|
||||
-- * 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`;
|
||||
-- ------------------------------------------------------------
|
||||
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;空数组表示未固定',
|
||||
`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 指令会立即停止下发。
|
||||
-- ------------------------------------------------------------
|
||||
43
yack-web/src/api/system/userSetting.js
Normal file
43
yack-web/src/api/system/userSetting.js
Normal file
@ -0,0 +1,43 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取当前用户的设置
|
||||
export function getUserSetting() {
|
||||
return request({
|
||||
url: '/system/userSetting',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 保存当前用户的设置
|
||||
export function updateUserSetting(data) {
|
||||
return request({
|
||||
url: '/system/userSetting',
|
||||
method: 'put',
|
||||
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 updatePinnedTags(paths) {
|
||||
return request({
|
||||
url: '/system/userSetting/pinnedTags',
|
||||
method: 'put',
|
||||
data: paths
|
||||
})
|
||||
}
|
||||
@ -88,6 +88,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 +281,76 @@ 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 阶段读到空列表就再也不会重建。
|
||||
this.$store.dispatch('tagsView/loadPinnedViews').then(paths => {
|
||||
paths
|
||||
// 路由里已不存在该 path(菜单被删/改名)时丢弃,避免产生打不开的死标签
|
||||
.map(path => this.findRouteByPath(path))
|
||||
.filter(Boolean)
|
||||
.forEach(({ route, path: fullPath }) => {
|
||||
// 路由自身声明的 affix 已在上一步加入,不重复添加
|
||||
if (!route.name || affixPaths.indexOf(fullPath) !== -1) {
|
||||
return
|
||||
}
|
||||
// 只取重建标签所需的字段,不要把路由定义上的 component/children 也塞进 visitedViews;
|
||||
// query/params 一律用空对象:固定标签来自右键菜单,天然不带参数,
|
||||
// 且 RouterVo.query 是菜单的默认参数 JSON 字符串,直接透传会被 vue-router 当成非法 query
|
||||
this.$store.dispatch('tagsView/addView', {
|
||||
path: fullPath,
|
||||
fullPath: fullPath,
|
||||
name: route.name,
|
||||
query: {},
|
||||
params: {},
|
||||
meta: Object.assign({}, route.meta, { affix: true, pinned: true })
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 按完整路径在当前路由表中递归查找对应路由
|
||||
*
|
||||
* 后端只存了 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,4 +1,11 @@
|
||||
const PINNED_STORAGE_KEY = 'pinned-tags'
|
||||
import { Message } from 'element-ui'
|
||||
import { getPinnedTags, updatePinnedTags } from '@/api/system/userSetting'
|
||||
|
||||
// 固定标签的持久化说明:
|
||||
// 原实现把标签写入浏览器 localStorage(key: pinned-tags),只能在本机本浏览器生效,
|
||||
// 换浏览器或换电脑就丢。现改为落库(sys_user_setting.pinned_tags),
|
||||
// 固定项按 path 存取,顺序即固定区顺序。
|
||||
// 本模块不再直接依赖 localStorage——组件初始化时从后端拉取,由 TagsView 触发。
|
||||
|
||||
// 只持久化「重新打开标签」所需的最小字段,避免把 meta 里的临时/函数数据写进 localStorage
|
||||
function toStorableView(view) {
|
||||
@ -13,21 +20,9 @@ function toStorableView(view) {
|
||||
}
|
||||
}
|
||||
|
||||
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 不可用(隐私模式/超配额)时降级为仅当前会话有效
|
||||
}
|
||||
// 落库时只提交 path 列表,其余字段可由路由表还原,避免把冗余的 title/icon 存进库
|
||||
function toPinnedPaths(list) {
|
||||
return (list || []).map(v => v.path).filter(p => !!p)
|
||||
}
|
||||
|
||||
// 固定区 = 排在普通标签之前的 affix 标签;新固定/取消固定的标签插到固定区末尾
|
||||
@ -40,8 +35,9 @@ const state = {
|
||||
visitedViews: [],
|
||||
cachedViews: [],
|
||||
iframeViews: [],
|
||||
// 用户右键「固定标签到任务栏」的标签,刷新后仍然存在
|
||||
pinnedViews: loadPinnedViews()
|
||||
// 用户右键「固定标签到任务栏」的标签,刷新/换设备后仍然存在
|
||||
// 初始为空,由 TagsView 在拿到后端数据后通过 SET_PINNED_VIEWS 填充
|
||||
pinnedViews: []
|
||||
}
|
||||
|
||||
const mutations = {
|
||||
@ -125,11 +121,9 @@ const mutations = {
|
||||
if (!state.pinnedViews.some(v => v.path === view.path)) {
|
||||
state.pinnedViews.push(toStorableView(target))
|
||||
}
|
||||
savePinnedViews(state.pinnedViews)
|
||||
},
|
||||
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]
|
||||
@ -138,6 +132,10 @@ const mutations = {
|
||||
state.visitedViews.splice(index, 1)
|
||||
moveToAffixBlockEnd(state.visitedViews, target)
|
||||
},
|
||||
// 从后端载入固定标签,tagsView 初始化时调用
|
||||
SET_PINNED_VIEWS: (state, list) => {
|
||||
state.pinnedViews = Array.isArray(list) ? list : []
|
||||
},
|
||||
// 拖拽排序:把 visitedViews 里的 fromIndex 挪到 toIndex(toIndex 是「最终落在」的位置)
|
||||
SORT_VISITED_VIEWS: (state, { fromIndex, toIndex }) => {
|
||||
const list = state.visitedViews
|
||||
@ -204,6 +202,45 @@ const actions = {
|
||||
addVisitedView({ commit }, view) {
|
||||
commit('ADD_VISITED_VIEW', view)
|
||||
},
|
||||
/**
|
||||
* 从后端载入该用户固定的任务栏标签
|
||||
*
|
||||
* 落库后固定项不再依赖 localStorage,换浏览器/换电脑登录同一账号即可恢复。
|
||||
* 失败时保持空列表,不阻塞标签栏渲染。
|
||||
*/
|
||||
loadPinnedViews({ commit }) {
|
||||
return getPinnedTags()
|
||||
.then(res => {
|
||||
const paths = Array.isArray(res.data) ? res.data : []
|
||||
// 只保留 path,完整 view 由 TagsView 用当前路由表补全
|
||||
commit('SET_PINNED_VIEWS', paths.map(p => ({ path: p })))
|
||||
return paths
|
||||
})
|
||||
.catch(() => {
|
||||
commit('SET_PINNED_VIEWS', [])
|
||||
return []
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 固定/取消固定后把最新 path 列表落库
|
||||
*
|
||||
* 失败时只提示不打断操作:内存中的标签区已经变了,
|
||||
* 用户不至于因为一次网络抖动失去反馈。
|
||||
*/
|
||||
persistPinnedViews({ state }) {
|
||||
return updatePinnedTags(toPinnedPaths(state.pinnedViews))
|
||||
.catch(() => {
|
||||
Message.warning('固定标签保存失败,下次登录可能不生效')
|
||||
})
|
||||
},
|
||||
pinView({ commit, dispatch, state }, view) {
|
||||
commit('PIN_VIEW', view)
|
||||
return dispatch('persistPinnedViews')
|
||||
},
|
||||
unpinView({ commit, dispatch, state }, view) {
|
||||
commit('UNPIN_VIEW', view)
|
||||
return dispatch('persistPinnedViews')
|
||||
},
|
||||
addCachedView({ commit }, view) {
|
||||
commit('ADD_CACHED_VIEW', view)
|
||||
},
|
||||
@ -282,14 +319,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)
|
||||
|
||||
@ -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;
|
||||
|
||||
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>
|
||||
104
yack-web/src/views/system/setting/components/UserSettingForm.vue
Normal file
104
yack-web/src/views/system/setting/components/UserSettingForm.vue
Normal file
@ -0,0 +1,104 @@
|
||||
<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>
|
||||
|
||||
<div class="setting-actions">
|
||||
<el-button type="primary" icon="el-icon-check" :loading="saving" @click="submitForm">保存</el-button>
|
||||
<el-button icon="el-icon-refresh-left" @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
getUserSetting,
|
||||
updateUserSetting,
|
||||
listLoginRedirectOptions
|
||||
} from '@/api/system/userSetting';
|
||||
import SettingRow from './SettingRow';
|
||||
|
||||
// 默认值与后端 UserSettingConstants.DEFAULT_LOGIN_REDIRECT 保持一致:
|
||||
// 未指定的账号保持原有的数据大屏落地行为
|
||||
const DEFAULT_FORM = {
|
||||
loginRedirect: '/bigscreen'
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'UserSettingForm',
|
||||
components: { SettingRow },
|
||||
data() {
|
||||
return {
|
||||
saving: false,
|
||||
optionsLoading: false,
|
||||
options: [],
|
||||
form: { ...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 || {};
|
||||
// 后端对未配置过的用户返回默认值,此处仅在值缺失时兜底
|
||||
this.form = {
|
||||
loginRedirect: data.loginRedirect || DEFAULT_FORM.loginRedirect
|
||||
};
|
||||
});
|
||||
},
|
||||
submitForm() {
|
||||
this.saving = true;
|
||||
updateUserSetting({ loginRedirect: this.form.loginRedirect })
|
||||
.then(() => {
|
||||
this.$modal.msgSuccess('保存成功,下次登录生效');
|
||||
})
|
||||
.finally(() => {
|
||||
this.saving = false;
|
||||
});
|
||||
},
|
||||
handleReset() {
|
||||
this.form = { ...DEFAULT_FORM };
|
||||
this.getSetting();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.setting-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// 动作按钮缩进到控件列下方(而不是清单左边缘):
|
||||
// 172px = SettingRow 的标签列 160px + 列间距 12px,见 SettingRow.vue
|
||||
.setting-actions {
|
||||
padding: 16px 0 0 172px;
|
||||
}
|
||||
</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