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