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-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",
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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: '登録設定' },
|
||||
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: '有効' },
|
||||
|
||||
@@ -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 { 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<MenuOption[]>(() => {
|
||||
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(() => {
|
||||
|
||||
@@ -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()
|
||||
<RegistrationConfigPanel />
|
||||
<BillSourceStatsPanel />
|
||||
<DisplayConfigPanel />
|
||||
<NavMenuConfigPanel />
|
||||
<AiConfigPanel />
|
||||
<NotificationConfigPanel />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user