feat: 左侧导航支持配置显隐与拖拽排序
在「设置」页新增「导航设置」面板,用户可拖拽调整左侧各导航项的顺序并控制显隐;配置按用户持久化到后端,跨设备生效。隐藏仅移除菜单入口,页面路由仍可访问,且至少保留一个可见项。 变更内容: - 后端新增 /api/setting/nav-menu 读写接口,复用 t_user_preference 键值存储,无需改表 - 新增 NavMenuPrefsVO / NavMenuPrefsRequest / NavMenuItem,保存时过滤空 key 与重复项 - 前端新增导航清单单一数据源与共享状态,主布局菜单据此渲染顺序与显隐 - 新增「导航设置」面板,支持拖拽排序、显隐开关、恢复默认,改动防抖自动保存 - 「智能查数」仍受 AI 启用状态约束,未启用时不在菜单与配置项中显示 - 新增导航设置相关中英日三语文案 - 新增拖拽依赖 vuedraggable@4 - 顺带将 Hikari 连接池名前缀由 finance- 修正为 ledgerly-
This commit is contained in:
@@ -19,7 +19,8 @@
|
|||||||
"vue": "^3.5.41",
|
"vue": "^3.5.41",
|
||||||
"vue-echarts": "^8.1.0",
|
"vue-echarts": "^8.1.0",
|
||||||
"vue-i18n": "^11.4.12",
|
"vue-i18n": "^11.4.12",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4",
|
||||||
|
"vuedraggable": "^4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.13.3",
|
"@types/node": "^24.13.3",
|
||||||
|
|||||||
@@ -40,3 +40,19 @@ export function getPaginationPrefs() {
|
|||||||
export function updatePaginationPrefs(data: { options: number[]; defaultSize: number }) {
|
export function updatePaginationPrefs(data: { options: number[]; defaultSize: number }) {
|
||||||
return request.put('/setting/pagination-prefs', data)
|
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 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// 导航设置面板:配置左侧导航的展示顺序与显隐,拖拽排序、修改后自动保存
|
||||||
|
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { NCard, NSpace, NIcon, NText, NSwitch, NButton, useMessage } from 'naive-ui'
|
||||||
|
import { MenuOutline, RefreshOutline, ReorderTwoOutline } from '@vicons/ionicons5'
|
||||||
|
import VueDraggable from 'vuedraggable'
|
||||||
|
import type { NavMenuItem } from '../api/setting'
|
||||||
|
import { navEntries, loadNavMenu, saveNavMenu, buildDefaultNavEntries, type NavEntry } from '../composables/useNavMenu'
|
||||||
|
import { aiEnabled, ensureAiEnabledLoaded } from '../composables/useAiEnabled'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
/** 当前可编辑的导航项:AI 未启用时不展示依赖 AI 的导航项(遵循 AI 元素显隐约定) */
|
||||||
|
const editableItems = computed(() => navEntries.value.filter((item) => !item.aiDependent || aiEnabled.value))
|
||||||
|
|
||||||
|
/** 面板内编辑副本,顺序即保存后的展示顺序 */
|
||||||
|
const draft = ref<NavEntry[]>([])
|
||||||
|
|
||||||
|
/** 初始加载完成前不触发自动保存 */
|
||||||
|
let ready = false
|
||||||
|
/** 重置等程序化改动期间跳过自动保存,避免重复提交 */
|
||||||
|
let syncing = false
|
||||||
|
/** 自动保存防抖计时器 */
|
||||||
|
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
/** 保存请求序号,用于丢弃过期响应 */
|
||||||
|
let saveSeq = 0
|
||||||
|
|
||||||
|
/** 当前草稿中处于可见状态的项数量 */
|
||||||
|
const visibleCount = computed(() => draft.value.filter((item) => item.visible).length)
|
||||||
|
|
||||||
|
/** 是否允许切换该导航项的显隐:可见项在仅剩一个时禁止关闭,保证至少保留一个可见项 */
|
||||||
|
function canToggle(item: NavEntry): boolean {
|
||||||
|
return !item.visible || visibleCount.value > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 防抖调度自动保存 */
|
||||||
|
function scheduleSave() {
|
||||||
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
saveTimer = null
|
||||||
|
void doSave()
|
||||||
|
}, 600)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交保存,返回是否成功 */
|
||||||
|
async function doSave(): Promise<boolean> {
|
||||||
|
const items: NavMenuItem[] = draft.value.map((item) => ({ key: item.key, visible: item.visible }))
|
||||||
|
const seq = ++saveSeq
|
||||||
|
try {
|
||||||
|
await saveNavMenu(items)
|
||||||
|
return true
|
||||||
|
} catch (e: any) {
|
||||||
|
// 已有更新的保存请求时,忽略本次失败提示
|
||||||
|
if (seq !== saveSeq) return true
|
||||||
|
message.error(e?.message || t('nav.saveFailed'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 恢复默认顺序与显隐并保存 */
|
||||||
|
async function handleReset() {
|
||||||
|
syncing = true
|
||||||
|
const defaults = buildDefaultNavEntries().filter((item) => !item.aiDependent || aiEnabled.value)
|
||||||
|
draft.value = defaults
|
||||||
|
await nextTick()
|
||||||
|
syncing = false
|
||||||
|
if (await doSave()) {
|
||||||
|
message.success(t('nav.resetSuccess'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖拽或开关变更后自动保存
|
||||||
|
watch(draft, () => {
|
||||||
|
if (!ready || syncing) return
|
||||||
|
scheduleSave()
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// AI 启用状态变化(如在本页保存 AI 配置)时,重建草稿以纳入/移除「智能查数」项,不触发自动保存
|
||||||
|
watch(aiEnabled, async () => {
|
||||||
|
if (!ready) return
|
||||||
|
syncing = true
|
||||||
|
draft.value = editableItems.value.map((item) => ({ ...item }))
|
||||||
|
await nextTick()
|
||||||
|
syncing = false
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 先确保 AI 启用状态就绪,避免初始化草稿时漏掉「智能查数」项
|
||||||
|
await ensureAiEnabledLoaded()
|
||||||
|
try {
|
||||||
|
await loadNavMenu()
|
||||||
|
} catch (e) {
|
||||||
|
message.error(t('nav.loadFailed'))
|
||||||
|
}
|
||||||
|
draft.value = editableItems.value.map((item) => ({ ...item }))
|
||||||
|
await nextTick()
|
||||||
|
ready = true
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NCard :bordered="false" class="config-card">
|
||||||
|
<template #header>
|
||||||
|
<NSpace align="center" :size="8">
|
||||||
|
<NIcon :component="MenuOutline" />
|
||||||
|
<span>{{ t('settings.navSection') }}</span>
|
||||||
|
</NSpace>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<NText depth="3" style="font-size: 12px; display: block; margin-bottom: 12px;">{{ t('nav.hint') }}</NText>
|
||||||
|
|
||||||
|
<VueDraggable v-model="draft" :animation="150" handle=".nav-drag-handle" item-key="key" class="nav-list">
|
||||||
|
<template #item="{ element }">
|
||||||
|
<div class="nav-row">
|
||||||
|
<NIcon class="nav-drag-handle" :component="ReorderTwoOutline" size="18" />
|
||||||
|
<NIcon :component="element.icon" size="18" />
|
||||||
|
<span class="nav-label">{{ t(element.labelKey) }}</span>
|
||||||
|
<NSpace align="center" :size="8">
|
||||||
|
<NText depth="3" style="font-size: 12px;">{{ element.visible ? t('nav.show') : t('nav.hide') }}</NText>
|
||||||
|
<NSwitch
|
||||||
|
:value="element.visible"
|
||||||
|
:disabled="!canToggle(element)"
|
||||||
|
@update:value="(value: boolean) => (element.visible = value)"
|
||||||
|
/>
|
||||||
|
</NSpace>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VueDraggable>
|
||||||
|
|
||||||
|
<NSpace justify="end" style="margin-top: 12px;">
|
||||||
|
<NButton size="small" @click="handleReset">
|
||||||
|
<template #icon>
|
||||||
|
<NIcon :component="RefreshOutline" />
|
||||||
|
</template>
|
||||||
|
{{ t('nav.resetDefault') }}
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</NCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.nav-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.nav-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fafafc;
|
||||||
|
border: 1px solid #efeff5;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.nav-drag-handle {
|
||||||
|
color: #b5b8bd;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.nav-drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
.nav-label {
|
||||||
|
flex: 1;
|
||||||
|
color: #333;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -144,6 +144,7 @@ export default {
|
|||||||
registrationSection: { zh: '注册设置', en: 'Registration', ja: '登録設定' },
|
registrationSection: { zh: '注册设置', en: 'Registration', ja: '登録設定' },
|
||||||
billSourceStatsSection: { zh: '账单来源统计默认值', en: 'Bill Source Stats Defaults', ja: '請求元の集計既定値' },
|
billSourceStatsSection: { zh: '账单来源统计默认值', en: 'Bill Source Stats Defaults', ja: '請求元の集計既定値' },
|
||||||
displaySection: { zh: '外观设置', en: 'Appearance', ja: '外観設定' },
|
displaySection: { zh: '外观设置', en: 'Appearance', ja: '外観設定' },
|
||||||
|
navSection: { zh: '导航设置', en: 'Navigation', ja: 'ナビゲーション設定' },
|
||||||
notificationSection: { zh: '定时通知', en: 'Scheduled Notifications', ja: '定期通知' },
|
notificationSection: { zh: '定时通知', en: 'Scheduled Notifications', ja: '定期通知' },
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -202,6 +203,19 @@ export default {
|
|||||||
pageSizeFailed: { zh: '保存每页条数选项失败', en: 'Failed to save page size options', ja: '1ページ件数オプションの保存に失敗しました' },
|
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: {
|
registration: {
|
||||||
statusOn: { zh: '已开启', en: 'Enabled', ja: '有効' },
|
statusOn: { zh: '已开启', en: 'Enabled', ja: '有効' },
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
]
|
||||||
@@ -5,13 +5,14 @@ import { useRouter, useRoute } from 'vue-router'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NSpace, NIcon, NDropdown } from 'naive-ui'
|
import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NSpace, NIcon, NDropdown } from 'naive-ui'
|
||||||
import type { MenuOption } 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 { useUserStore } from '../store/user'
|
||||||
import LocaleSwitch from '../components/LocaleSwitch.vue'
|
import LocaleSwitch from '../components/LocaleSwitch.vue'
|
||||||
import { getChannels } from '../api/transaction'
|
import { getChannels } from '../api/transaction'
|
||||||
import { readSessionString, writeSessionString } from '../utils/storage'
|
import { readSessionString, writeSessionString } from '../utils/storage'
|
||||||
import { scrollKey } from '../utils/storageKeys'
|
import { scrollKey } from '../utils/storageKeys'
|
||||||
import { aiEnabled, refreshAiEnabled } from '../composables/useAiEnabled'
|
import { aiEnabled, refreshAiEnabled } from '../composables/useAiEnabled'
|
||||||
|
import { navEntries, loadNavMenu } from '../composables/useNavMenu'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -43,20 +44,11 @@ function renderIcon(icon: any) {
|
|||||||
return () => h(NIcon, null, { default: () => h(icon) })
|
return () => h(NIcon, null, { default: () => h(icon) })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 侧边栏菜单配置项(随语言切换响应式更新;智能查数仅在 AI 启用时展示)
|
// 侧边栏菜单配置项(随语言切换与用户导航配置响应式更新;智能查数仅在 AI 启用时展示)
|
||||||
const menuOptions = computed<MenuOption[]>(() => {
|
const menuOptions = computed<MenuOption[]>(() => {
|
||||||
const options: MenuOption[] = [
|
return navEntries.value
|
||||||
{ label: t('layout.dashboard'), key: 'dashboard', icon: renderIcon(BarChartOutline) },
|
.filter((item) => item.visible && (!item.aiDependent || aiEnabled.value))
|
||||||
{ label: t('layout.transactions'), key: 'transactions', icon: renderIcon(ListOutline) },
|
.map((item) => ({ label: t(item.labelKey), key: item.key, icon: renderIcon(item.icon) }))
|
||||||
{ 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
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 菜单点击事件:根据 key 导航到对应路由 */
|
/** 菜单点击事件:根据 key 导航到对应路由 */
|
||||||
@@ -194,6 +186,8 @@ onMounted(() => {
|
|||||||
loadChannels()
|
loadChannels()
|
||||||
// 加载 AI 启用状态,控制「智能查数」菜单显隐(每次登录进入布局时刷新,避免沿用上一账号状态)
|
// 加载 AI 启用状态,控制「智能查数」菜单显隐(每次登录进入布局时刷新,避免沿用上一账号状态)
|
||||||
refreshAiEnabled()
|
refreshAiEnabled()
|
||||||
|
// 加载用户导航菜单配置,保证顺序与显隐按上次保存的结果渲染
|
||||||
|
loadNavMenu(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import DatabaseConfigPanel from '../components/DatabaseConfigPanel.vue'
|
|||||||
import RegistrationConfigPanel from '../components/RegistrationConfigPanel.vue'
|
import RegistrationConfigPanel from '../components/RegistrationConfigPanel.vue'
|
||||||
import BillSourceStatsPanel from '../components/BillSourceStatsPanel.vue'
|
import BillSourceStatsPanel from '../components/BillSourceStatsPanel.vue'
|
||||||
import DisplayConfigPanel from '../components/DisplayConfigPanel.vue'
|
import DisplayConfigPanel from '../components/DisplayConfigPanel.vue'
|
||||||
|
import NavMenuConfigPanel from '../components/NavMenuConfigPanel.vue'
|
||||||
import AiConfigPanel from '../components/AiConfigPanel.vue'
|
import AiConfigPanel from '../components/AiConfigPanel.vue'
|
||||||
import NotificationConfigPanel from '../components/NotificationConfigPanel.vue'
|
import NotificationConfigPanel from '../components/NotificationConfigPanel.vue'
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ const { t } = useI18n()
|
|||||||
<RegistrationConfigPanel />
|
<RegistrationConfigPanel />
|
||||||
<BillSourceStatsPanel />
|
<BillSourceStatsPanel />
|
||||||
<DisplayConfigPanel />
|
<DisplayConfigPanel />
|
||||||
|
<NavMenuConfigPanel />
|
||||||
<AiConfigPanel />
|
<AiConfigPanel />
|
||||||
<NotificationConfigPanel />
|
<NotificationConfigPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
import cn.rosercode.ledgerly.common.Result;
|
import cn.rosercode.ledgerly.common.Result;
|
||||||
import cn.rosercode.ledgerly.dto.BillSourceStatsRequest;
|
import cn.rosercode.ledgerly.dto.BillSourceStatsRequest;
|
||||||
import cn.rosercode.ledgerly.dto.ExcludedDisplayStyleRequest;
|
import cn.rosercode.ledgerly.dto.ExcludedDisplayStyleRequest;
|
||||||
|
import cn.rosercode.ledgerly.dto.NavMenuPrefsRequest;
|
||||||
import cn.rosercode.ledgerly.dto.PaginationPrefsRequest;
|
import cn.rosercode.ledgerly.dto.PaginationPrefsRequest;
|
||||||
import cn.rosercode.ledgerly.dto.ShowHiddenDataRequest;
|
import cn.rosercode.ledgerly.dto.ShowHiddenDataRequest;
|
||||||
import cn.rosercode.ledgerly.service.UserPreferenceService;
|
import cn.rosercode.ledgerly.service.UserPreferenceService;
|
||||||
import cn.rosercode.ledgerly.util.UserContext;
|
import cn.rosercode.ledgerly.util.UserContext;
|
||||||
|
import cn.rosercode.ledgerly.vo.NavMenuPrefsVO;
|
||||||
import cn.rosercode.ledgerly.vo.PaginationPrefsVO;
|
import cn.rosercode.ledgerly.vo.PaginationPrefsVO;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,4 +143,31 @@ public class UserSettingController {
|
|||||||
userPreferenceService.savePaginationPrefs(userId, prefs);
|
userPreferenceService.savePaginationPrefs(userId, prefs);
|
||||||
return Result.success(userPreferenceService.getPaginationPrefs(userId));
|
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 java.util.Map;
|
||||||
|
|
||||||
|
import cn.rosercode.ledgerly.vo.NavMenuPrefsVO;
|
||||||
import cn.rosercode.ledgerly.vo.PaginationPrefsVO;
|
import cn.rosercode.ledgerly.vo.PaginationPrefsVO;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,4 +75,20 @@ public interface UserPreferenceService {
|
|||||||
* @param prefs 分页偏好
|
* @param prefs 分页偏好
|
||||||
*/
|
*/
|
||||||
void savePaginationPrefs(Long userId, PaginationPrefsVO 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) {
|
private HikariDataSource createDataSource(DatabaseConfig config) {
|
||||||
HikariConfig hikari = new HikariConfig();
|
HikariConfig hikari = new HikariConfig();
|
||||||
hikari.setPoolName("finance-" + config.getType().name().toLowerCase());
|
hikari.setPoolName("ledgerly-" + config.getType().name().toLowerCase());
|
||||||
hikari.setDriverClassName(config.getType().getDriverClassName());
|
hikari.setDriverClassName(config.getType().getDriverClassName());
|
||||||
hikari.setJdbcUrl(config.buildJdbcUrl());
|
hikari.setJdbcUrl(config.buildJdbcUrl());
|
||||||
hikari.setConnectionTimeout(10_000);
|
hikari.setConnectionTimeout(10_000);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package cn.rosercode.ledgerly.service.impl;
|
package cn.rosercode.ledgerly.service.impl;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -21,6 +23,8 @@ import cn.rosercode.ledgerly.entity.UserPreference;
|
|||||||
import cn.rosercode.ledgerly.enums.BillType;
|
import cn.rosercode.ledgerly.enums.BillType;
|
||||||
import cn.rosercode.ledgerly.mapper.UserPreferenceMapper;
|
import cn.rosercode.ledgerly.mapper.UserPreferenceMapper;
|
||||||
import cn.rosercode.ledgerly.service.UserPreferenceService;
|
import cn.rosercode.ledgerly.service.UserPreferenceService;
|
||||||
|
import cn.rosercode.ledgerly.vo.NavMenuItem;
|
||||||
|
import cn.rosercode.ledgerly.vo.NavMenuPrefsVO;
|
||||||
import cn.rosercode.ledgerly.vo.PaginationPrefsVO;
|
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_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);
|
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());
|
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