From e7ebf3b9187fc7577f294c7028277c09b82593ef Mon Sep 17 00:00:00 2001 From: zhugongping Date: Mon, 5 Oct 2026 14:17:37 +0800 Subject: [PATCH] =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E8=AE=BE=E7=BD=AE=EF=BC=9A?= =?UTF-8?q?=E5=8F=AF=E4=BB=A5=E9=85=8D=E7=BD=AE=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../system/SysUserSettingController.java | 104 ++++++++ .../common/constant/CacheConstants.java | 5 + .../common/constant/UserSettingConstants.java | 79 ++++++ .../fastbee/common/core/redis/RedisCache.java | 26 ++ .../executor/PowerOnTriggerTaskExecutor.java | 27 ++- .../system/domain/LoginRedirectOption.java | 50 ++++ .../fastbee/system/domain/SysUserSetting.java | 111 +++++++++ .../system/mapper/SysUserSettingMapper.java | 47 ++++ .../service/ISysUserSettingService.java | 61 +++++ .../impl/SysUserSettingServiceImpl.java | 228 ++++++++++++++++++ .../mapper/system/SysUserSettingMapper.xml | 98 ++++++++ sql/sys_user_setting.sql | 80 ++++++ yack-web/src/api/system/userSetting.js | 43 ++++ .../src/layout/components/TagsView/index.vue | 95 +++++++- yack-web/src/permission.js | 12 +- yack-web/src/router/index.js | 5 +- yack-web/src/store/modules/tagsView.js | 85 ++++--- yack-web/src/views/login.vue | 54 +++-- .../system/setting/components/SettingRow.vue | 75 ++++++ .../setting/components/UserSettingForm.vue | 104 ++++++++ yack-web/src/views/system/setting/index.vue | 60 +++++ .../src/views/system/user/profile/index.vue | 13 +- 22 files changed, 1400 insertions(+), 62 deletions(-) create mode 100644 fastbee-admin/src/main/java/com/fastbee/web/controller/system/SysUserSettingController.java create mode 100644 fastbee-common/src/main/java/com/fastbee/common/constant/UserSettingConstants.java create mode 100644 fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/LoginRedirectOption.java create mode 100644 fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/SysUserSetting.java create mode 100644 fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/mapper/SysUserSettingMapper.java create mode 100644 fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/ISysUserSettingService.java create mode 100644 fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/impl/SysUserSettingServiceImpl.java create mode 100644 fastbee-service/fastbee-system-service/src/main/resources/mapper/system/SysUserSettingMapper.xml create mode 100644 sql/sys_user_setting.sql create mode 100644 yack-web/src/api/system/userSetting.js create mode 100644 yack-web/src/views/system/setting/components/SettingRow.vue create mode 100644 yack-web/src/views/system/setting/components/UserSettingForm.vue create mode 100644 yack-web/src/views/system/setting/index.vue diff --git a/fastbee-admin/src/main/java/com/fastbee/web/controller/system/SysUserSettingController.java b/fastbee-admin/src/main/java/com/fastbee/web/controller/system/SysUserSettingController.java new file mode 100644 index 00000000..f12b19a7 --- /dev/null +++ b/fastbee-admin/src/main/java/com/fastbee/web/controller/system/SysUserSettingController.java @@ -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; + +/** + * 个人系统设置 + * + *

本接口只处理「当前登录用户自己的」设置,无需额外的菜单权限点: + * 能登录的用户天然有权读写自己的偏好。全局开关不在这里, + * 仍走 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 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(); + } + + /** + * 校验跳转目标为系统内已注册的一级页签 + * + *

该值最终进入 router.push。白名单取自 sys_menu(已剔除停用/隐藏/外链/二级嵌套页), + * 比单纯校验「以 / 开头」更严:只能落到真实存在的菜单路由上, + * 既挡住开放重定向,也避免指向被删除的菜单导致登录后 404。 + */ + private boolean isValidLoginRedirect(String redirect) { + for (LoginRedirectOption option : userSettingService.listLoginRedirectOptions()) { + if (option.getPath().equals(redirect)) { + return true; + } + } + return false; + } +} diff --git a/fastbee-common/src/main/java/com/fastbee/common/constant/CacheConstants.java b/fastbee-common/src/main/java/com/fastbee/common/constant/CacheConstants.java index e213bc6b..86455227 100644 --- a/fastbee-common/src/main/java/com/fastbee/common/constant/CacheConstants.java +++ b/fastbee-common/src/main/java/com/fastbee/common/constant/CacheConstants.java @@ -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 */ diff --git a/fastbee-common/src/main/java/com/fastbee/common/constant/UserSettingConstants.java b/fastbee-common/src/main/java/com/fastbee/common/constant/UserSettingConstants.java new file mode 100644 index 00000000..f5b2ee71 --- /dev/null +++ b/fastbee-common/src/main/java/com/fastbee/common/constant/UserSettingConstants.java @@ -0,0 +1,79 @@ +package com.fastbee.common.constant; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * 用户系统设置相关常量 + * + *

维度判据:读取方能否拿到当前登录用户。 + * 拿得到(HTTP 请求,有 SecurityContext)→ 落 {@code sys_user_setting},per-user; + * 拿不到(设备上电 {@code @Async} 线程)→ 落 {@code sys_config},全局。 + * + * @author Sumuxi + */ +public class UserSettingConstants { + + /** + * 登录后默认跳转路由 + * + *

取 /bigscreen:未在设置里指定页面的账号保持原有的数据大屏落地行为, + * 不因引入本功能而改变既有用户的使用习惯。 + * 对应 yack-web router/index.js constantRoutes 中 hidden 的顶层大屏路由。 + */ + public static final String DEFAULT_LOGIN_REDIRECT = "/bigscreen"; + + /** + * 内置固定路由:不在 sys_menu 菜单表中,但可作为登录落地页 + * + *

这两条由前端 constantRoutes 硬编码注册(菜单表里查不到): + * /index —— 首页,Layout 的默认子路由 + * /bigscreen—— 数据大屏,hidden 菜单,任何角色都可访问 + * 候选项 = 内置路由 + sys_menu 一级页签,缺一则默认值 /index 会被白名单拒绝。 + */ + public static final List BUILTIN_REDIRECTS = Collections.unmodifiableList(Arrays.asList( + new BuiltinRedirect("/index", "首页"), + new BuiltinRedirect("/bigscreen", "数据大屏") + )); + + /** + * 是否下发读取设备 IMEI/ICCID 指令的全局配置 key + * + *

读取方为设备上电异步线程,无登录上下文,故存 {@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"; + + /** + * 固定标签的「未固定」空值 + * + *

列约束为 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; + } + } +} diff --git a/fastbee-common/src/main/java/com/fastbee/common/core/redis/RedisCache.java b/fastbee-common/src/main/java/com/fastbee/common/core/redis/RedisCache.java index d74beacf..f6e6be99 100644 --- a/fastbee-common/src/main/java/com/fastbee/common/core/redis/RedisCache.java +++ b/fastbee-common/src/main/java/com/fastbee/common/core/redis/RedisCache.java @@ -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); + } + /** * 删除单个对象 * diff --git a/fastbee-iot-data/src/main/java/com/fastbee/iot/data/executor/PowerOnTriggerTaskExecutor.java b/fastbee-iot-data/src/main/java/com/fastbee/iot/data/executor/PowerOnTriggerTaskExecutor.java index f4cbc188..3fff51e0 100644 --- a/fastbee-iot-data/src/main/java/com/fastbee/iot/data/executor/PowerOnTriggerTaskExecutor.java +++ b/fastbee-iot-data/src/main/java/com/fastbee/iot/data/executor/PowerOnTriggerTaskExecutor.java @@ -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指令」全局开关 + * + *

配置缺失时按开启处理:设备出厂时 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 设备编号 diff --git a/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/LoginRedirectOption.java b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/LoginRedirectOption.java new file mode 100644 index 00000000..f10531e9 --- /dev/null +++ b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/LoginRedirectOption.java @@ -0,0 +1,50 @@ +package com.fastbee.system.domain; + +import java.io.Serializable; + +/** + * 登录落地页候选项(系统菜单) + * + *

数据源为 {@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; + } +} diff --git a/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/SysUserSetting.java b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/SysUserSetting.java new file mode 100644 index 00000000..3e73555a --- /dev/null +++ b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/domain/SysUserSetting.java @@ -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 + * + *

一行一用户(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 + * + *

只存用户右键「固定标签到任务栏」的 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(); + } +} diff --git a/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/mapper/SysUserSettingMapper.java b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/mapper/SysUserSettingMapper.java new file mode 100644 index 00000000..40f66ea4 --- /dev/null +++ b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/mapper/SysUserSettingMapper.java @@ -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); + + /** + * 仅更新固定标签列 + * + *

用户尚无记录时更新 0 行,调用方需自行判断是否改用 insert。 + * + * @param setting 设置,userId 与 pinnedTags 必填 + * @return 影响行数 + */ + int updatePinnedTags(SysUserSetting setting); + + /** + * 查询登录落地页候选项(可作为页签的菜单) + * + * @return 候选项列表 + */ + List selectLoginRedirectOptions(); +} diff --git a/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/ISysUserSettingService.java b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/ISysUserSettingService.java new file mode 100644 index 00000000..5927aac3 --- /dev/null +++ b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/ISysUserSettingService.java @@ -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; + +/** + * 用户系统设置 服务层 + * + *

所有方法的 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 listLoginRedirectOptions(); + + /** + * 保存任务栏固定标签 + * + *

只落用户右键固定的 pinned 集合;路由自身声明的 affix 不在其中。 + * + * @param userId 用户ID + * @param paths 固定标签的路由 path 列表,按固定顺序 + * @param operator 操作人用户名,写入 update_by + */ + void savePinnedTags(Long userId, List paths, String operator); + + /** + * 查询任务栏固定标签 + * + * @param userId 用户ID + * @return 固定标签的路由 path 列表,按固定顺序;未固定时返回空列表 + */ + List getPinnedTags(Long userId); +} diff --git a/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/impl/SysUserSettingServiceImpl.java b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/impl/SysUserSettingServiceImpl.java new file mode 100644 index 00000000..d4e0eed6 --- /dev/null +++ b/fastbee-service/fastbee-system-service/src/main/java/com/fastbee/system/service/impl/SysUserSettingServiceImpl.java @@ -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; + +/** + * 用户系统设置 服务层实现 + * + *

读取策略:Redis 缓存未命中则查库并回填,未配置过则返回默认值对象。 + * 缓存值为空表示「该用户还没有记录」,此时不写入缓存—— + * 否则用户首次保存前,每次读都会穿透到数据库。 + * + * @author Sumuxi + */ +@Slf4j +@Service +public class SysUserSettingServiceImpl implements ISysUserSettingService { + + /** + * 缓存值的字段分隔符 + * + *

用 \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 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 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 getPinnedTags(Long userId) { + return parsePinnedTags(selectByUserId(userId).getPinnedTags()); + } + + /** + * 校验并序列化固定标签列表 + * + *

只接受站内绝对路径,去重且保持前端给定的固定顺序。 + * path 最终会进 router.push,混入外链会变成开放重定向入口。 + * + * @param paths 前端提交的 path 列表 + * @return JSON 数组字符串 + */ + private String normalizePinnedTags(List paths) { + List 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); + } + + /** + * 解析固定标签,脏数据降级为空列表 + * + *

列是 TEXT,允许人工改过;解析失败不应让整个设置页报错。 + * + * @param json JSON 数组字符串 + * @return path 列表 + */ + private List parsePinnedTags(String json) { + if (StringUtils.isEmpty(json)) { + return new ArrayList<>(); + } + try { + List 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 listLoginRedirectOptions() { + List options = new ArrayList<>(); + // 内置固定路由优先:/index 与 /bigscreen 由前端 constantRoutes 注册,不在 sys_menu 中 + for (UserSettingConstants.BuiltinRedirect builtin : UserSettingConstants.BUILTIN_REDIRECTS) { + options.add(new LoginRedirectOption(builtin.getPath(), builtin.getName())); + } + List 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; + } +} diff --git a/fastbee-service/fastbee-system-service/src/main/resources/mapper/system/SysUserSettingMapper.xml b/fastbee-service/fastbee-system-service/src/main/resources/mapper/system/SysUserSettingMapper.xml new file mode 100644 index 00000000..6477db12 --- /dev/null +++ b/fastbee-service/fastbee-system-service/src/main/resources/mapper/system/SysUserSettingMapper.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + select setting_id, user_id, login_redirect, pinned_tags, + create_by, create_time, update_by, update_time, remark + from sys_user_setting + + + + + + + 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) + + + + + update sys_user_setting + set pinned_tags = #{pinnedTags}, + update_by = #{updateBy}, + update_time = #{updateTime} + where user_id = #{userId} + + + + + + diff --git a/sql/sys_user_setting.sql b/sql/sys_user_setting.sql new file mode 100644 index 00000000..3b6fb968 --- /dev/null +++ b/sql/sys_user_setting.sql @@ -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 指令会立即停止下发。 +-- ------------------------------------------------------------ diff --git a/yack-web/src/api/system/userSetting.js b/yack-web/src/api/system/userSetting.js new file mode 100644 index 00000000..7791f5f4 --- /dev/null +++ b/yack-web/src/api/system/userSetting.js @@ -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 + }) +} diff --git a/yack-web/src/layout/components/TagsView/index.vue b/yack-web/src/layout/components/TagsView/index.vue index 4b33af48..08cf470b 100644 --- a/yack-web/src/layout/components/TagsView/index.vue +++ b/yack-web/src/layout/components/TagsView/index.vue @@ -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 diff --git a/yack-web/src/permission.js b/yack-web/src/permission.js index 9df13bc9..a5b4acd1 100644 --- a/yack-web/src/permission.js +++ b/yack-web/src/permission.js @@ -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(); } } diff --git a/yack-web/src/router/index.js b/yack-web/src/router/index.js index 5a4ab484..0f417e90 100644 --- a/yack-web/src/router/index.js +++ b/yack-web/src/router/index.js @@ -71,7 +71,10 @@ export const constantRoutes = [ { path: '', component: Layout, - redirect: '/bigscreen', + // 根路径兜底到首页。系统设置里的「登录后显示页面」不能在这里生效—— + // 路由表是静态定义的、生成时拿不到异步配置,硬拼会与 permission.js 形成跳转循环。 + // 实际落地页由 login.vue 的 resolveLoginRedirect 决定,这里只承接「已登录用户访问 /」。 + redirect: '/index', children: [ { path: 'index', diff --git a/yack-web/src/store/modules/tagsView.js b/yack-web/src/store/modules/tagsView.js index 9fcafe66..8a308496 100644 --- a/yack-web/src/store/modules/tagsView.js +++ b/yack-web/src/store/modules/tagsView.js @@ -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) diff --git a/yack-web/src/views/login.vue b/yack-web/src/views/login.vue index 72c51806..6cd3da95 100644 --- a/yack-web/src/views/login.vue +++ b/yack-web/src/views/login.vue @@ -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; diff --git a/yack-web/src/views/system/setting/components/SettingRow.vue b/yack-web/src/views/system/setting/components/SettingRow.vue new file mode 100644 index 00000000..0bbaae57 --- /dev/null +++ b/yack-web/src/views/system/setting/components/SettingRow.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/yack-web/src/views/system/setting/components/UserSettingForm.vue b/yack-web/src/views/system/setting/components/UserSettingForm.vue new file mode 100644 index 00000000..df8c08cb --- /dev/null +++ b/yack-web/src/views/system/setting/components/UserSettingForm.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/yack-web/src/views/system/setting/index.vue b/yack-web/src/views/system/setting/index.vue new file mode 100644 index 00000000..773ba697 --- /dev/null +++ b/yack-web/src/views/system/setting/index.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/yack-web/src/views/system/user/profile/index.vue b/yack-web/src/views/system/user/profile/index.vue index 3288e0cb..30b22bab 100644 --- a/yack-web/src/views/system/user/profile/index.vue +++ b/yack-web/src/views/system/user/profile/index.vue @@ -18,6 +18,10 @@ 修改密码 +

  • + + 个人设置 +
  • @@ -39,6 +43,12 @@
    +
    +
    个人设置
    +
    + +
    +
    @@ -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,