feat: 左侧导航支持配置显隐与拖拽排序

在「设置」页新增「导航设置」面板,用户可拖拽调整左侧各导航项的顺序并控制显隐;配置按用户持久化到后端,跨设备生效。隐藏仅移除菜单入口,页面路由仍可访问,且至少保留一个可见项。

变更内容:
- 后端新增 /api/setting/nav-menu 读写接口,复用 t_user_preference 键值存储,无需改表
- 新增 NavMenuPrefsVO / NavMenuPrefsRequest / NavMenuItem,保存时过滤空 key 与重复项
- 前端新增导航清单单一数据源与共享状态,主布局菜单据此渲染顺序与显隐
- 新增「导航设置」面板,支持拖拽排序、显隐开关、恢复默认,改动防抖自动保存
- 「智能查数」仍受 AI 启用状态约束,未启用时不在菜单与配置项中显示
- 新增导航设置相关中英日三语文案
- 新增拖拽依赖 vuedraggable@4
- 顺带将 Hikari 连接池名前缀由 finance- 修正为 ledgerly-
This commit is contained in:
2026-09-24 11:01:14 +08:00
parent 0e1443c59e
commit 49d16bec82
15 changed files with 538 additions and 17 deletions
@@ -13,10 +13,12 @@ import org.springframework.web.bind.annotation.RestController;
import cn.rosercode.ledgerly.common.Result;
import cn.rosercode.ledgerly.dto.BillSourceStatsRequest;
import cn.rosercode.ledgerly.dto.ExcludedDisplayStyleRequest;
import cn.rosercode.ledgerly.dto.NavMenuPrefsRequest;
import cn.rosercode.ledgerly.dto.PaginationPrefsRequest;
import cn.rosercode.ledgerly.dto.ShowHiddenDataRequest;
import cn.rosercode.ledgerly.service.UserPreferenceService;
import cn.rosercode.ledgerly.util.UserContext;
import cn.rosercode.ledgerly.vo.NavMenuPrefsVO;
import cn.rosercode.ledgerly.vo.PaginationPrefsVO;
/**
@@ -141,4 +143,31 @@ public class UserSettingController {
userPreferenceService.savePaginationPrefs(userId, prefs);
return Result.success(userPreferenceService.getPaginationPrefs(userId));
}
/**
* 查询当前用户配置的侧边导航菜单偏好。
*
* @return 导航菜单偏好(顺序与显隐),未配置时 items 为空
*/
@GetMapping("/nav-menu")
@Operation(summary = "查询导航菜单偏好", description = "读取当前用户侧边导航菜单的展示顺序与显隐配置")
public Result<NavMenuPrefsVO> getNavMenu() {
return Result.success(userPreferenceService.getNavMenu(UserContext.getUserId()));
}
/**
* 更新当前用户配置的侧边导航菜单偏好。
*
* @param request 导航菜单偏好更新请求
* @return 更新后的导航菜单偏好
*/
@PutMapping("/nav-menu")
@Operation(summary = "更新导航菜单偏好", description = "设置当前用户侧边导航菜单的展示顺序与显隐配置")
public Result<NavMenuPrefsVO> updateNavMenu(@RequestBody NavMenuPrefsRequest request) {
Long userId = UserContext.getUserId();
NavMenuPrefsVO prefs = new NavMenuPrefsVO();
prefs.setItems(request.getItems());
userPreferenceService.saveNavMenu(userId, prefs);
return Result.success(userPreferenceService.getNavMenu(userId));
}
}
@@ -0,0 +1,19 @@
package cn.rosercode.ledgerly.dto;
import java.util.List;
import lombok.Data;
import cn.rosercode.ledgerly.vo.NavMenuItem;
/**
* 侧边导航菜单偏好更新请求对象。
*
* @date 2026-09-24 10:43:24
*/
@Data
public class NavMenuPrefsRequest {
/** 导航菜单项有序列表,顺序即期望的侧边栏展示顺序 */
private List<NavMenuItem> items;
}
@@ -2,6 +2,7 @@ package cn.rosercode.ledgerly.service;
import java.util.Map;
import cn.rosercode.ledgerly.vo.NavMenuPrefsVO;
import cn.rosercode.ledgerly.vo.PaginationPrefsVO;
/**
@@ -70,8 +71,24 @@ public interface UserPreferenceService {
/**
* 保存当前用户配置的表格分页偏好,空或非法时回退默认值。
*
* @param userId 用户ID
* @param userId 用户ID
* @param prefs 分页偏好
*/
void savePaginationPrefs(Long userId, PaginationPrefsVO prefs);
/**
* 获取当前用户配置的侧边导航菜单偏好(顺序与显隐),未配置时返回空列表。
*
* @param userId 用户ID
* @return 导航菜单偏好,items 为空表示未配置
*/
NavMenuPrefsVO getNavMenu(Long userId);
/**
* 保存当前用户配置的侧边导航菜单偏好,空项或非法项会被过滤。
*
* @param userId 用户ID
* @param prefs 导航菜单偏好
*/
void saveNavMenu(Long userId, NavMenuPrefsVO prefs);
}
@@ -224,7 +224,7 @@ public class DatabaseServiceImpl implements DatabaseService {
*/
private HikariDataSource createDataSource(DatabaseConfig config) {
HikariConfig hikari = new HikariConfig();
hikari.setPoolName("finance-" + config.getType().name().toLowerCase());
hikari.setPoolName("ledgerly-" + config.getType().name().toLowerCase());
hikari.setDriverClassName(config.getType().getDriverClassName());
hikari.setJdbcUrl(config.buildJdbcUrl());
hikari.setConnectionTimeout(10_000);
@@ -1,6 +1,8 @@
package cn.rosercode.ledgerly.service.impl;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -21,6 +23,8 @@ import cn.rosercode.ledgerly.entity.UserPreference;
import cn.rosercode.ledgerly.enums.BillType;
import cn.rosercode.ledgerly.mapper.UserPreferenceMapper;
import cn.rosercode.ledgerly.service.UserPreferenceService;
import cn.rosercode.ledgerly.vo.NavMenuItem;
import cn.rosercode.ledgerly.vo.NavMenuPrefsVO;
import cn.rosercode.ledgerly.vo.PaginationPrefsVO;
/**
@@ -46,6 +50,12 @@ public class UserPreferenceServiceImpl implements UserPreferenceService {
/** 偏好键:表格分页偏好(每页条数候选列表与默认每页条数) */
private static final String KEY_PAGINATION_PREFS = "paginationPrefs";
/** 偏好键:侧边导航菜单偏好(顺序与显隐) */
private static final String KEY_NAV_MENU = "navMenu";
/** 导航菜单项数量上限,避免异常数据无限增长 */
private static final int MAX_NAV_MENU_ITEMS = 100;
/** 默认的表格分页每页条数候选列表 */
private static final List<Integer> DEFAULT_PAGE_SIZE_OPTIONS = List.of(10, 20, 50, 100);
@@ -192,6 +202,87 @@ public class UserPreferenceServiceImpl implements UserPreferenceService {
userId, normalized.getOptions(), normalized.getDefaultSize());
}
/**
* {@inheritDoc}
*
* 结果按用户缓存,未配置时返回空列表(由前端回退默认顺序与可见性)。
*/
@Override
@Cacheable(cacheNames = CacheConfig.USER_PREFERENCE_CACHE, key = "'navMenu:' + #userId")
public NavMenuPrefsVO getNavMenu(Long userId) {
return normalizeNavMenu(parseNavMenuPrefs(findValue(userId, KEY_NAV_MENU)));
}
/**
* {@inheritDoc}
*
* 保存后失效该用户的导航菜单偏好缓存。
*/
@Override
@CacheEvict(cacheNames = CacheConfig.USER_PREFERENCE_CACHE, key = "'navMenu:' + #userId")
public void saveNavMenu(Long userId, NavMenuPrefsVO prefs) {
NavMenuPrefsVO normalized = normalizeNavMenu(prefs);
upsert(userId, KEY_NAV_MENU, toJson(normalized));
long visibleCount = normalized.getItems().stream()
.filter(item -> Boolean.TRUE.equals(item.getVisible()))
.count();
log.info("保存导航菜单偏好: userId={}, total={}, visible={}",
userId, normalized.getItems().size(), visibleCount);
}
/**
* 规范化导航菜单偏好:过滤空 key 与重复 key(按首次出现保留顺序)、visible 缺省视为可见、
* 限制菜单项数量;未配置时返回空列表。
*
* @param prefs 原始导航菜单偏好
* @return 规范化后的导航菜单偏好
*/
private NavMenuPrefsVO normalizeNavMenu(NavMenuPrefsVO prefs) {
List<NavMenuItem> items = prefs != null ? prefs.getItems() : null;
List<NavMenuItem> normalized = new ArrayList<>();
Set<String> seen = new HashSet<>();
if (items != null) {
for (NavMenuItem item : items) {
if (item == null || item.getKey() == null || item.getKey().isBlank()) {
continue;
}
String key = item.getKey().trim();
if (!seen.add(key)) {
continue;
}
if (normalized.size() >= MAX_NAV_MENU_ITEMS) {
break;
}
NavMenuItem target = new NavMenuItem();
target.setKey(key);
// 未显式传入 visible 时默认可见,保证配置缺省语义友好
target.setVisible(item.getVisible() == null || item.getVisible());
normalized.add(target);
}
}
NavMenuPrefsVO result = new NavMenuPrefsVO();
result.setItems(normalized);
return result;
}
/**
* 将 JSON 字符串解析为导航菜单偏好,解析失败或为空时返回 null。
*
* @param json JSON 字符串
* @return 导航菜单偏好,解析失败时为 null
*/
private NavMenuPrefsVO parseNavMenuPrefs(String json) {
if (json == null || json.isBlank()) {
return null;
}
try {
return objectMapper.readValue(json, NavMenuPrefsVO.class);
} catch (JsonProcessingException e) {
log.warn("解析导航菜单偏好失败:{}", e.getMessage());
return null;
}
}
/**
* 规范化分页偏好:候选列表过滤非正整数与越界值、去重升序并限制数量(为空回退默认);
* 默认每页条数须为候选列表内的正整数,否则回退候选列表首项。
@@ -0,0 +1,18 @@
package cn.rosercode.ledgerly.vo;
import lombok.Data;
/**
* 侧边导航菜单项用户配置,包含菜单标识与是否可见。
*
* @date 2026-09-24 10:43:24
*/
@Data
public class NavMenuItem {
/** 菜单项标识,对应前端导航项的 key */
private String key;
/** 是否在侧边导航中可见 */
private Boolean visible;
}
@@ -0,0 +1,17 @@
package cn.rosercode.ledgerly.vo;
import java.util.List;
import lombok.Data;
/**
* 侧边导航菜单用户偏好视图对象,按数组顺序表示菜单展示顺序。
*
* @date 2026-09-24 10:43:24
*/
@Data
public class NavMenuPrefsVO {
/** 导航菜单项有序列表,顺序即侧边栏展示顺序;为空表示未配置 */
private List<NavMenuItem> items;
}