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,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>
|
||||
Reference in New Issue
Block a user