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
+103
View File
@@ -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<NavEntry[]>(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<string>()
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<NavEntry[]> {
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<NavEntry[]> {
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 }
}