feat: 左侧导航支持配置显隐与拖拽排序
在「设置」页新增「导航设置」面板,用户可拖拽调整左侧各导航项的顺序并控制显隐;配置按用户持久化到后端,跨设备生效。隐藏仅移除菜单入口,页面路由仍可访问,且至少保留一个可见项。 变更内容: - 后端新增 /api/setting/nav-menu 读写接口,复用 t_user_preference 键值存储,无需改表 - 新增 NavMenuPrefsVO / NavMenuPrefsRequest / NavMenuItem,保存时过滤空 key 与重复项 - 前端新增导航清单单一数据源与共享状态,主布局菜单据此渲染顺序与显隐 - 新增「导航设置」面板,支持拖拽排序、显隐开关、恢复默认,改动防抖自动保存 - 「智能查数」仍受 AI 启用状态约束,未启用时不在菜单与配置项中显示 - 新增导航设置相关中英日三语文案 - 新增拖拽依赖 vuedraggable@4 - 顺带将 Hikari 连接池名前缀由 finance- 修正为 ledgerly-
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user