diff --git a/frontend/package.json b/frontend/package.json index a86f5f7..a906e28 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,7 +19,8 @@ "vue": "^3.5.41", "vue-echarts": "^8.1.0", "vue-i18n": "^11.4.12", - "vue-router": "^4.6.4" + "vue-router": "^4.6.4", + "vuedraggable": "^4.1.0" }, "devDependencies": { "@types/node": "^24.13.3", diff --git a/frontend/src/api/setting.ts b/frontend/src/api/setting.ts index a47d50c..077e783 100644 --- a/frontend/src/api/setting.ts +++ b/frontend/src/api/setting.ts @@ -40,3 +40,19 @@ export function getPaginationPrefs() { export function updatePaginationPrefs(data: { options: number[]; defaultSize: number }) { return request.put('/setting/pagination-prefs', data) } + +/** 导航菜单项配置:key 为导航项标识,visible 控制其在侧边栏的显隐,数组顺序即展示顺序 */ +export interface NavMenuItem { + key: string + visible: boolean +} + +/** 查询当前用户配置的侧边导航菜单偏好(顺序与显隐) */ +export function getNavMenu() { + return request.get('/setting/nav-menu') +} + +/** 更新当前用户配置的侧边导航菜单偏好 */ +export function updateNavMenu(items: NavMenuItem[]) { + return request.put('/setting/nav-menu', { items }) +} diff --git a/frontend/src/components/NavMenuConfigPanel.vue b/frontend/src/components/NavMenuConfigPanel.vue new file mode 100644 index 0000000..8829fa6 --- /dev/null +++ b/frontend/src/components/NavMenuConfigPanel.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/frontend/src/composables/useNavMenu.ts b/frontend/src/composables/useNavMenu.ts new file mode 100644 index 0000000..d0e42b8 --- /dev/null +++ b/frontend/src/composables/useNavMenu.ts @@ -0,0 +1,103 @@ +// 侧边导航菜单配置共享状态:从后端读取用户级顺序与显隐配置,与本地默认清单合并后供主布局与设置页共用 +import { ref } from 'vue' +import type { NavItemDef } from '../utils/navItems' +import { NAV_ITEMS } from '../utils/navItems' +import type { NavMenuItem } from '../api/setting' +import { getNavMenu, updateNavMenu } from '../api/setting' + +/** 合并后的导航项:在默认定义基础上附带用户配置的可见性 */ +export interface NavEntry extends NavItemDef { + /** 用户配置的可见性(仅移除菜单入口,不影响路由访问) */ + visible: boolean +} + +/** 按默认清单生成全部可见的导航项 */ +export function buildDefaultNavEntries(): NavEntry[] { + return NAV_ITEMS.map((def) => ({ ...def, visible: def.defaultVisible })) +} + +// 模块级单例,供主布局与设置页响应式共享 +export const navEntries = ref(buildDefaultNavEntries()) +let loaded = false + +/** + * 将后端配置与本地默认清单合并: + * - 过滤未知或重复的 key,按后端顺序保留; + * - 后端未记录的导航项按默认顺序插入到其默认前驱项之后,保持默认相对位置与可见性。 + * + * @param raw 后端返回的菜单项列表 + * @returns 合并后的导航项列表 + */ +function mergeFromServer(raw: NavMenuItem[] | null | undefined): NavEntry[] { + const defMap = new Map(NAV_ITEMS.map((def) => [def.key, def])) + const result: NavEntry[] = [] + const seen = new Set() + const list = Array.isArray(raw) ? raw : [] + for (const item of list) { + if (!item || typeof item.key !== 'string') continue + const def = defMap.get(item.key) + if (!def || seen.has(item.key)) continue + seen.add(item.key) + result.push({ ...def, visible: item.visible !== false }) + } + // 补全后端未记录的导航项,插入到默认顺序中前面最近的已存在项之后 + for (let i = 0; i < NAV_ITEMS.length; i++) { + const def = NAV_ITEMS[i] + if (seen.has(def.key)) continue + let insertAt = 0 + for (let j = i - 1; j >= 0; j--) { + const idx = result.findIndex((entry) => entry.key === NAV_ITEMS[j].key) + if (idx >= 0) { + insertAt = idx + 1 + break + } + } + seen.add(def.key) + result.splice(insertAt, 0, { ...def, visible: def.defaultVisible }) + } + return result +} + +/** + * 从后端加载导航菜单偏好;force 为 true 时强制刷新(登录进入布局或设置页保存后调用)。 + * 加载失败时保持默认(全部按默认顺序可见),不阻塞布局渲染。 + * + * @param force 是否强制重新加载 + * @returns 合并后的导航项列表 + */ +export async function loadNavMenu(force = false): Promise { + if (loaded && !force) return navEntries.value + try { + const res: any = await getNavMenu() + navEntries.value = mergeFromServer(res?.data?.items) + loaded = true + } catch (e) { + // 加载失败保持默认配置 + } + return navEntries.value +} + +/** + * 保存导航菜单偏好,并以后端返回结果刷新共享状态。 + * + * @param items 待保存的菜单项(顺序即展示顺序) + * @returns 保存后的导航项列表 + */ +export async function saveNavMenu(items: NavMenuItem[]): Promise { + const res: any = await updateNavMenu(items) + navEntries.value = mergeFromServer(res?.data?.items) + loaded = true + return navEntries.value +} + +/** + * 获取共享的导航项列表,首次调用时异步加载。 + * + * @returns 导航项列表 ref + */ +export function useNavMenu() { + if (!loaded) { + void loadNavMenu() + } + return { navEntries } +} diff --git a/frontend/src/locales/messages.ts b/frontend/src/locales/messages.ts index da3e937..814cd45 100644 --- a/frontend/src/locales/messages.ts +++ b/frontend/src/locales/messages.ts @@ -144,6 +144,7 @@ export default { registrationSection: { zh: '注册设置', en: 'Registration', ja: '登録設定' }, billSourceStatsSection: { zh: '账单来源统计默认值', en: 'Bill Source Stats Defaults', ja: '請求元の集計既定値' }, displaySection: { zh: '外观设置', en: 'Appearance', ja: '外観設定' }, + navSection: { zh: '导航设置', en: 'Navigation', ja: 'ナビゲーション設定' }, notificationSection: { zh: '定时通知', en: 'Scheduled Notifications', ja: '定期通知' }, }, @@ -202,6 +203,19 @@ export default { pageSizeFailed: { zh: '保存每页条数选项失败', en: 'Failed to save page size options', ja: '1ページ件数オプションの保存に失敗しました' }, }, + // 导航设置 + nav: { + hint: { zh: '调整左侧导航的显示顺序与显隐;拖动手柄可排序,隐藏后仅移除菜单入口,页面仍可通过地址访问', en: 'Adjust the order and visibility of the left navigation. Drag the handle to reorder; hidden items are removed from the menu but remain accessible by URL', ja: '左側ナビゲーションの並び順と表示・非表示を調整します。ハンドルをドラッグして並べ替えでき、非表示にしても URL からアクセスできます' }, + dragHint: { zh: '拖动手柄调整顺序', en: 'Drag the handle to reorder', ja: 'ハンドルをドラッグして並べ替え' }, + show: { zh: '显示', en: 'Show', ja: '表示' }, + hide: { zh: '隐藏', en: 'Hide', ja: '非表示' }, + resetDefault: { zh: '恢复默认', en: 'Reset to default', ja: '既定に戻す' }, + resetSuccess: { zh: '已恢复默认导航设置', en: 'Navigation reset to default', ja: 'ナビゲーションを既定に戻しました' }, + loadFailed: { zh: '加载导航设置失败', en: 'Failed to load navigation settings', ja: 'ナビゲーション設定の読み込みに失敗しました' }, + saveFailed: { zh: '保存导航设置失败', en: 'Failed to save navigation settings', ja: 'ナビゲーション設定の保存に失敗しました' }, + hideLastForbidden: { zh: '至少保留一个可见导航', en: 'Keep at least one navigation item visible', ja: 'ナビゲーションを1つ以上表示してください' }, + }, + // 注册设置 registration: { statusOn: { zh: '已开启', en: 'Enabled', ja: '有効' }, diff --git a/frontend/src/utils/navItems.ts b/frontend/src/utils/navItems.ts new file mode 100644 index 0000000..dfd51cb --- /dev/null +++ b/frontend/src/utils/navItems.ts @@ -0,0 +1,30 @@ +// 侧边导航清单(单一数据源):定义各导航项的标识、文案、图标与默认显隐/顺序,供主布局与设置页共用 +import { markRaw, type Component } from 'vue' +import { + BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, OptionsOutline, MailOutline, SparklesOutline, +} from '@vicons/ionicons5' + +/** 导航项定义 */ +export interface NavItemDef { + /** 导航项标识,同时作为菜单 key 与路由 path 首段 */ + key: string + /** 显示文案的 i18n key */ + labelKey: string + /** 菜单图标组件 */ + icon: Component + /** 是否依赖 AI 启用状态:为 true 时仅在 AI 启用后展示 */ + aiDependent: boolean + /** 默认是否可见 */ + defaultVisible: boolean +} + +/** 默认导航项清单,数组顺序即默认展示顺序;图标用 markRaw 避免被响应式包装 */ +export const NAV_ITEMS: NavItemDef[] = [ + { key: 'dashboard', labelKey: 'layout.dashboard', icon: markRaw(BarChartOutline), aiDependent: false, defaultVisible: true }, + { key: 'smart-query', labelKey: 'layout.sqlQuery', icon: markRaw(SparklesOutline), aiDependent: true, defaultVisible: true }, + { key: 'transactions', labelKey: 'layout.transactions', icon: markRaw(ListOutline), aiDependent: false, defaultVisible: true }, + { key: 'categories', labelKey: 'layout.categories', icon: markRaw(FolderOpenOutline), aiDependent: false, defaultVisible: true }, + { key: 'tags', labelKey: 'layout.tags', icon: markRaw(PricetagsOutline), aiDependent: false, defaultVisible: true }, + { key: 'rules', labelKey: 'layout.rules', icon: markRaw(OptionsOutline), aiDependent: false, defaultVisible: true }, + { key: 'email-bills', labelKey: 'layout.emailBills', icon: markRaw(MailOutline), aiDependent: false, defaultVisible: true }, +] diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue index 50bd9ee..1a7418a 100644 --- a/frontend/src/views/Layout.vue +++ b/frontend/src/views/Layout.vue @@ -5,13 +5,14 @@ import { useRouter, useRoute } from 'vue-router' import { useI18n } from 'vue-i18n' import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NSpace, NIcon, NDropdown } from 'naive-ui' import type { MenuOption } from 'naive-ui' -import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, OptionsOutline, MailOutline, DocumentTextOutline, SettingsOutline, LogOutOutline, ChevronDownOutline, WalletOutline, SparklesOutline } from '@vicons/ionicons5' +import { DocumentTextOutline, SettingsOutline, LogOutOutline, ChevronDownOutline, WalletOutline } from '@vicons/ionicons5' import { useUserStore } from '../store/user' import LocaleSwitch from '../components/LocaleSwitch.vue' import { getChannels } from '../api/transaction' import { readSessionString, writeSessionString } from '../utils/storage' import { scrollKey } from '../utils/storageKeys' import { aiEnabled, refreshAiEnabled } from '../composables/useAiEnabled' +import { navEntries, loadNavMenu } from '../composables/useNavMenu' const router = useRouter() const route = useRoute() @@ -43,20 +44,11 @@ function renderIcon(icon: any) { return () => h(NIcon, null, { default: () => h(icon) }) } -// 侧边栏菜单配置项(随语言切换响应式更新;智能查数仅在 AI 启用时展示) +// 侧边栏菜单配置项(随语言切换与用户导航配置响应式更新;智能查数仅在 AI 启用时展示) const menuOptions = computed(() => { - const options: MenuOption[] = [ - { label: t('layout.dashboard'), key: 'dashboard', icon: renderIcon(BarChartOutline) }, - { label: t('layout.transactions'), key: 'transactions', icon: renderIcon(ListOutline) }, - { label: t('layout.categories'), key: 'categories', icon: renderIcon(FolderOpenOutline) }, - { label: t('layout.tags'), key: 'tags', icon: renderIcon(PricetagsOutline) }, - { label: t('layout.rules'), key: 'rules', icon: renderIcon(OptionsOutline) }, - { label: t('layout.emailBills'), key: 'email-bills', icon: renderIcon(MailOutline) }, - ] - if (aiEnabled.value) { - options.splice(1, 0, { label: t('layout.sqlQuery'), key: 'smart-query', icon: renderIcon(SparklesOutline) }) - } - return options + return navEntries.value + .filter((item) => item.visible && (!item.aiDependent || aiEnabled.value)) + .map((item) => ({ label: t(item.labelKey), key: item.key, icon: renderIcon(item.icon) })) }) /** 菜单点击事件:根据 key 导航到对应路由 */ @@ -194,6 +186,8 @@ onMounted(() => { loadChannels() // 加载 AI 启用状态,控制「智能查数」菜单显隐(每次登录进入布局时刷新,避免沿用上一账号状态) refreshAiEnabled() + // 加载用户导航菜单配置,保证顺序与显隐按上次保存的结果渲染 + loadNavMenu(true) }) onBeforeUnmount(() => { diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue index b7922f4..045c937 100644 --- a/frontend/src/views/Settings.vue +++ b/frontend/src/views/Settings.vue @@ -6,6 +6,7 @@ import DatabaseConfigPanel from '../components/DatabaseConfigPanel.vue' import RegistrationConfigPanel from '../components/RegistrationConfigPanel.vue' import BillSourceStatsPanel from '../components/BillSourceStatsPanel.vue' import DisplayConfigPanel from '../components/DisplayConfigPanel.vue' +import NavMenuConfigPanel from '../components/NavMenuConfigPanel.vue' import AiConfigPanel from '../components/AiConfigPanel.vue' import NotificationConfigPanel from '../components/NotificationConfigPanel.vue' @@ -20,6 +21,7 @@ const { t } = useI18n() + diff --git a/src/main/java/cn/rosercode/ledgerly/controller/UserSettingController.java b/src/main/java/cn/rosercode/ledgerly/controller/UserSettingController.java index 8b0055c..f27193c 100644 --- a/src/main/java/cn/rosercode/ledgerly/controller/UserSettingController.java +++ b/src/main/java/cn/rosercode/ledgerly/controller/UserSettingController.java @@ -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 getNavMenu() { + return Result.success(userPreferenceService.getNavMenu(UserContext.getUserId())); + } + + /** + * 更新当前用户配置的侧边导航菜单偏好。 + * + * @param request 导航菜单偏好更新请求 + * @return 更新后的导航菜单偏好 + */ + @PutMapping("/nav-menu") + @Operation(summary = "更新导航菜单偏好", description = "设置当前用户侧边导航菜单的展示顺序与显隐配置") + public Result 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)); + } } diff --git a/src/main/java/cn/rosercode/ledgerly/dto/NavMenuPrefsRequest.java b/src/main/java/cn/rosercode/ledgerly/dto/NavMenuPrefsRequest.java new file mode 100644 index 0000000..187d5c9 --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/dto/NavMenuPrefsRequest.java @@ -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 items; +} diff --git a/src/main/java/cn/rosercode/ledgerly/service/UserPreferenceService.java b/src/main/java/cn/rosercode/ledgerly/service/UserPreferenceService.java index 1846a38..b508974 100644 --- a/src/main/java/cn/rosercode/ledgerly/service/UserPreferenceService.java +++ b/src/main/java/cn/rosercode/ledgerly/service/UserPreferenceService.java @@ -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); } diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/DatabaseServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/DatabaseServiceImpl.java index d255a21..7e9f72d 100644 --- a/src/main/java/cn/rosercode/ledgerly/service/impl/DatabaseServiceImpl.java +++ b/src/main/java/cn/rosercode/ledgerly/service/impl/DatabaseServiceImpl.java @@ -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); diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/UserPreferenceServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/UserPreferenceServiceImpl.java index 3a803f9..df25c73 100644 --- a/src/main/java/cn/rosercode/ledgerly/service/impl/UserPreferenceServiceImpl.java +++ b/src/main/java/cn/rosercode/ledgerly/service/impl/UserPreferenceServiceImpl.java @@ -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 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 items = prefs != null ? prefs.getItems() : null; + List normalized = new ArrayList<>(); + Set 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; + } + } + /** * 规范化分页偏好:候选列表过滤非正整数与越界值、去重升序并限制数量(为空回退默认); * 默认每页条数须为候选列表内的正整数,否则回退候选列表首项。 diff --git a/src/main/java/cn/rosercode/ledgerly/vo/NavMenuItem.java b/src/main/java/cn/rosercode/ledgerly/vo/NavMenuItem.java new file mode 100644 index 0000000..099d0c7 --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/vo/NavMenuItem.java @@ -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; +} diff --git a/src/main/java/cn/rosercode/ledgerly/vo/NavMenuPrefsVO.java b/src/main/java/cn/rosercode/ledgerly/vo/NavMenuPrefsVO.java new file mode 100644 index 0000000..3b5bf6d --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/vo/NavMenuPrefsVO.java @@ -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 items; +}