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
@@ -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>