feat: 设置页新增分区目录导航并支持 URL 锚点,统计面板图表点击跳转可关闭

设置页由单一长列表改为「目录 + 分区」结构:桌面左侧吸附目录、小屏/平板顶部横向条目条,点击可平滑定位并同步 URL 锚点(支持 /settings#<slug> 深链与浏览器前进后退);同时为统计面板图表补充用户级「点击跳转明细」开关(默认开启),面板与设置页两处入口实时联动。

变更内容:
- 设置页目录导航:Settings.vue 分区 slug 化,点击 push / 滚动 replace 同步 URL,显式跳转期间暂停滚动联动并用 ResizeObserver 持续对齐、用户主动滚动后解除;修复悬停样式覆盖高亮、并让选中项自动滚入视野;Layout.vue 在 route.hash 非空时跳过滚动位置恢复;router 对同路径纯 hash 变更提前放行;messages.ts 新增目录三语文案。
- 统计面板图表点击开关:后端新增用户级偏好 `chartClickNavigation`(DTO/Controller/Service,未配置默认开启);前端共享 composable `useChartClickNavigation`;Dashboard.vue 增加关闭守卫、提示显隐与光标联动及面板快捷开关;DisplayConfigPanel.vue 新增开关;messages.ts 新增三语文案。
- 说明:复用通用键值表 t_user_preference,不改动建表脚本与实体/Mapper;未配置默认开启,老用户无感知。
This commit is contained in:
2026-10-13 17:42:08 +08:00
parent 4aeeaac94f
commit bfd314a38e
12 changed files with 627 additions and 18 deletions
+10
View File
@@ -31,6 +31,16 @@ export function updateShowHiddenData(show: boolean) {
return request.put('/setting/show-hidden-data', { show }) return request.put('/setting/show-hidden-data', { show })
} }
/** 查询当前用户是否启用统计面板图表的点击跳转明细(默认开启) */
export function getChartClickNavigation() {
return request.get('/setting/chart-click-navigation')
}
/** 更新当前用户是否启用统计面板图表的点击跳转明细 */
export function updateChartClickNavigation(enabled: boolean) {
return request.put('/setting/chart-click-navigation', { enabled })
}
/** 查询当前用户配置的表格分页偏好(每页条数候选列表与默认每页条数) */ /** 查询当前用户配置的表格分页偏好(每页条数候选列表与默认每页条数) */
export function getPaginationPrefs() { export function getPaginationPrefs() {
return request.get('/setting/pagination-prefs') return request.get('/setting/pagination-prefs')
@@ -11,8 +11,10 @@ import {
updateShowHiddenData, updateShowHiddenData,
getPaginationPrefs, getPaginationPrefs,
updatePaginationPrefs, updatePaginationPrefs,
updateChartClickNavigation,
} from '../api/setting' } from '../api/setting'
import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE_OPTIONS, loadPageSizeOptions } from '../composables/usePageSizeOptions' import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE_OPTIONS, loadPageSizeOptions } from '../composables/usePageSizeOptions'
import { chartClickEnabled, ensureChartClickEnabledLoaded } from '../composables/useChartClickNavigation'
const message = useMessage() const message = useMessage()
const { t } = useI18n() const { t } = useI18n()
@@ -26,6 +28,9 @@ const showHiddenData = ref(false)
const loadingHidden = ref(false) const loadingHidden = ref(false)
const savingHidden = ref(false) const savingHidden = ref(false)
/** 图表点击跳转偏好:与统计面板共享全局状态 */
const savingChartClick = ref(false)
/** 表格分页每页条数候选列表(字符串标签形式,提交时解析为数字) */ /** 表格分页每页条数候选列表(字符串标签形式,提交时解析为数字) */
const pageSizeTags = ref<string[]>([]) const pageSizeTags = ref<string[]>([])
/** 默认每页条数 */ /** 默认每页条数 */
@@ -123,6 +128,24 @@ async function handleHiddenChange(value: string | number | boolean) {
} }
} }
/** 切换「图表点击跳转明细」偏好并即时保存,失败时回滚 */
async function handleChartClickChange(value: string | number | boolean) {
const next = value === true
const previous = chartClickEnabled.value
chartClickEnabled.value = next
savingChartClick.value = true
try {
const res: any = await updateChartClickNavigation(next)
chartClickEnabled.value = res?.data !== false
message.success(t('display.chartClickSaved'))
} catch (e: any) {
chartClickEnabled.value = previous
message.error(e.message || t('display.chartClickFailed'))
} finally {
savingChartClick.value = false
}
}
/** 应用后端返回的分页偏好到表单,回填期间跳过自动保存,避免循环提交 */ /** 应用后端返回的分页偏好到表单,回填期间跳过自动保存,避免循环提交 */
function applyPaginationPrefs(data: any, fallbackOptions: number[], fallbackDefault: number) { function applyPaginationPrefs(data: any, fallbackOptions: number[], fallbackDefault: number) {
syncingFromServer = true syncingFromServer = true
@@ -183,6 +206,7 @@ onMounted(() => {
loadStyle() loadStyle()
loadHiddenData() loadHiddenData()
loadPageSizes() loadPageSizes()
ensureChartClickEnabledLoaded()
}) })
</script> </script>
@@ -242,6 +266,14 @@ onMounted(() => {
</NSpace> </NSpace>
</div> </div>
<div class="config-group">
<div class="group-title">{{ t('display.chartClickLabel') }}</div>
<NSpace align="center" :size="12">
<NSwitch :value="chartClickEnabled" :loading="savingChartClick" @update:value="handleChartClickChange" />
<NText depth="3" style="font-size: 12px">{{ t('display.chartClickHint') }}</NText>
</NSpace>
</div>
<div class="config-group"> <div class="config-group">
<div class="group-title">{{ t('display.pageSizeLabel') }}</div> <div class="group-title">{{ t('display.pageSizeLabel') }}</div>
<NText depth="3" style="font-size: 12px; display: block; margin-bottom: 8px">{{ <NText depth="3" style="font-size: 12px; display: block; margin-bottom: 8px">{{
@@ -0,0 +1,35 @@
// 图表点击跳转偏好共享 composable:全局单例,供侧边栏设置页与统计面板统一读写
import { ref } from 'vue'
import { getChartClickNavigation } from '../api/setting'
/** 当前用户是否启用统计面板图表的点击跳转明细;读取失败一律按默认开启处理 */
export const chartClickEnabled = ref(true)
/** 偏好是否已确定(首次读取完成前为 false,避免误显示为关闭) */
export const chartClickEnabledLoaded = ref(false)
/**
* 从后端刷新图表点击跳转偏好。
* 设置页保存后调用,可让统计面板即时联动。
*/
export async function refreshChartClickEnabled() {
try {
const res: any = await getChartClickNavigation()
chartClickEnabled.value = res?.data !== false
} catch (e) {
chartClickEnabled.value = true
}
chartClickEnabledLoaded.value = true
}
/** 确保至少加载过一次图表点击跳转偏好(已加载则不重复请求) */
export async function ensureChartClickEnabledLoaded() {
if (chartClickEnabledLoaded.value) return
await refreshChartClickEnabled()
}
/** 重置为默认开启(退出登录等场景使用) */
export function resetChartClickEnabled() {
chartClickEnabled.value = true
chartClickEnabledLoaded.value = false
}
+26
View File
@@ -213,6 +213,7 @@ export default {
// 系统设置 // 系统设置
settings: { settings: {
title: { zh: '设置', en: 'Settings', ja: '設定' }, title: { zh: '设置', en: 'Settings', ja: '設定' },
navTitle: { zh: '设置目录', en: 'Contents', ja: '目次' },
accountSection: { zh: '账号安全', en: 'Account Security', ja: 'アカウントセキュリティ' }, accountSection: { zh: '账号安全', en: 'Account Security', ja: 'アカウントセキュリティ' },
databaseSection: { zh: '数据库配置', en: 'Database', ja: 'データベース' }, databaseSection: { zh: '数据库配置', en: 'Database', ja: 'データベース' },
aiSection: { zh: 'AI 模型配置', en: 'AI Model', ja: 'AI モデル' }, aiSection: { zh: 'AI 模型配置', en: 'AI Model', ja: 'AI モデル' },
@@ -352,6 +353,26 @@ export default {
en: 'Failed to update hidden remark record display', en: 'Failed to update hidden remark record display',
ja: '非表示備考の記録表示の更新に失敗しました', ja: '非表示備考の記録表示の更新に失敗しました',
}, },
chartClickLabel: {
zh: '图表点击跳转明细',
en: 'Chart click to view records',
ja: 'グラフのクリックで明細表示',
},
chartClickHint: {
zh: '开启后,点击统计面板的趋势柱子或饼图扇区可跳转查看对应收支明细;关闭后仅展示图表',
en: 'When on, clicking a trend bar or pie slice in the dashboard jumps to the matching transaction records; when off, charts are display-only',
ja: 'オンにすると、ダッシュボードの棒グラフや円グラフをクリックして該当する収支明細を表示できます。オフの場合はグラフの表示のみ',
},
chartClickSaved: {
zh: '已更新图表点击跳转设置',
en: 'Chart click navigation updated',
ja: 'グラフのクリック設定を更新しました',
},
chartClickFailed: {
zh: '更新图表点击跳转设置失败',
en: 'Failed to update chart click navigation',
ja: 'グラフのクリック設定の更新に失敗しました',
},
pageSizeLabel: { zh: '表格每页条数选项', en: 'Table page size options', ja: 'テーブルの1ページ件数オプション' }, pageSizeLabel: { zh: '表格每页条数选项', en: 'Table page size options', ja: 'テーブルの1ページ件数オプション' },
pageSizeHint: { pageSizeHint: {
zh: '所有表格分页共用的每页条数候选列表,修改后自动保存;未配置时使用默认 10 / 20 / 50 / 100', zh: '所有表格分页共用的每页条数候选列表,修改后自动保存;未配置时使用默认 10 / 20 / 50 / 100',
@@ -708,6 +729,11 @@ export default {
en: 'Click a bar to view matching records', en: 'Click a bar to view matching records',
ja: '棒をクリックすると該当明細を表示', ja: '棒をクリックすると該当明細を表示',
}, },
chartClickToggle: {
zh: '点击图表查看明细',
en: 'Click chart to view records',
ja: 'グラフをクリックして明細表示',
},
expenseCategoryPie: { zh: '支出分类占比', en: 'Expense by Category', ja: '支出カテゴリの割合' }, expenseCategoryPie: { zh: '支出分类占比', en: 'Expense by Category', ja: '支出カテゴリの割合' },
incomeCategoryPie: { zh: '收入分类占比', en: 'Income by Category', ja: '収入カテゴリの割合' }, incomeCategoryPie: { zh: '收入分类占比', en: 'Income by Category', ja: '収入カテゴリの割合' },
expenseChannelPie: { zh: '支出交易渠道占比', en: 'Expense by Channel', ja: '支出の取引チャネル割合' }, expenseChannelPie: { zh: '支出交易渠道占比', en: 'Expense by Channel', ja: '支出の取引チャネル割合' },
+5
View File
@@ -125,6 +125,11 @@ const router = createRouter({
// 全局前置守卫:优先校验数据库配置,再校验登录状态 // 全局前置守卫:优先校验数据库配置,再校验登录状态
router.beforeEach(async (to, _from, next) => { router.beforeEach(async (to, _from, next) => {
// 仅 hash/query 变化(路径不变)时无需重复执行数据库与登录校验,直接放行
if (to.path === _from.path) {
next()
return
}
const databaseStore = useDatabaseStore() const databaseStore = useDatabaseStore()
// 首次进入时从后端加载数据库配置状态 // 首次进入时从后端加载数据库配置状态
if (!databaseStore.loaded) { if (!databaseStore.loaded) {
+52 -5
View File
@@ -15,6 +15,7 @@ import {
NInputNumber, NInputNumber,
NButton, NButton,
NIcon, NIcon,
NSwitch,
useMessage, useMessage,
} from 'naive-ui' } from 'naive-ui'
import { SparklesOutline } from '@vicons/ionicons5' import { SparklesOutline } from '@vicons/ionicons5'
@@ -31,6 +32,8 @@ import { STORAGE_KEYS } from '../utils/storageKeys'
import { loadTransactionFilterPrefs, saveTransactionFilterPrefs } from '../utils/transactionFilterPrefs' import { loadTransactionFilterPrefs, saveTransactionFilterPrefs } from '../utils/transactionFilterPrefs'
import AiQueryWindow from '../components/AiQueryWindow.vue' import AiQueryWindow from '../components/AiQueryWindow.vue'
import { useBreakpoints } from '../composables/useBreakpoints' import { useBreakpoints } from '../composables/useBreakpoints'
import { updateChartClickNavigation } from '../api/setting'
import { chartClickEnabled, ensureChartClickEnabledLoaded } from '../composables/useChartClickNavigation'
// 小屏适配:图表高度随断点降低,避免在窄屏占用过多纵向空间 // 小屏适配:图表高度随断点降低,避免在窄屏占用过多纵向空间
const { isMobile, isTouch, viewportWidth } = useBreakpoints() const { isMobile, isTouch, viewportWidth } = useBreakpoints()
@@ -285,7 +288,7 @@ function updatePieChart() {
{ {
type: 'pie', type: 'pie',
radius: ['40%', '70%'], radius: ['40%', '70%'],
cursor: 'pointer', cursor: chartClickEnabled.value ? 'pointer' : 'default',
data: categoryData.value data: categoryData.value
.filter((c: any) => c.type === 'EXPENSE') .filter((c: any) => c.type === 'EXPENSE')
.map((c: any) => ({ .map((c: any) => ({
@@ -308,7 +311,7 @@ function updateIncomePieChart() {
{ {
type: 'pie', type: 'pie',
radius: ['40%', '70%'], radius: ['40%', '70%'],
cursor: 'pointer', cursor: chartClickEnabled.value ? 'pointer' : 'default',
data: categoryData.value data: categoryData.value
.filter((c: any) => c.type === 'INCOME') .filter((c: any) => c.type === 'INCOME')
.map((c: any) => ({ .map((c: any) => ({
@@ -331,7 +334,7 @@ function updateChannelChart() {
{ {
type: 'pie', type: 'pie',
radius: ['40%', '70%'], radius: ['40%', '70%'],
cursor: 'pointer', cursor: chartClickEnabled.value ? 'pointer' : 'default',
data: channelData.value data: channelData.value
.filter((p: any) => Number(p.expense) > 0) .filter((p: any) => Number(p.expense) > 0)
.map((p: any) => ({ .map((p: any) => ({
@@ -354,7 +357,7 @@ function updateIncomeChannelChart() {
{ {
type: 'pie', type: 'pie',
radius: ['40%', '70%'], radius: ['40%', '70%'],
cursor: 'pointer', cursor: chartClickEnabled.value ? 'pointer' : 'default',
data: channelData.value data: channelData.value
.filter((p: any) => Number(p.income) > 0) .filter((p: any) => Number(p.income) > 0)
.map((p: any) => ({ .map((p: any) => ({
@@ -542,6 +545,8 @@ const chartActionStyle = computed(() => {
* :param action: 实际执行跳转的回调 * :param action: 实际执行跳转的回调
*/ */
function triggerChartNavigation(params: any, action: () => void) { function triggerChartNavigation(params: any, action: () => void) {
// 关闭点击跳转后,图表元素不再响应点击
if (!chartClickEnabled.value) return
if (!isTouch.value) { if (!isTouch.value) {
action() action()
return return
@@ -565,6 +570,31 @@ function closeChartAction() {
chartAction.value = null chartAction.value = null
} }
/** 图表点击跳转偏好保存中(面板快捷开关) */
const savingChartClick = ref(false)
/**
* 面板内快捷开关:切换图表点击跳转偏好并即时保存,失败时回滚
*
* :param value: 开关新值
*/
async function handleChartClickToggle(value: string | number | boolean) {
const next = value === true
const previous = chartClickEnabled.value
chartClickEnabled.value = next
savingChartClick.value = true
try {
const res: any = await updateChartClickNavigation(next)
chartClickEnabled.value = res?.data !== false
message.success(t('display.chartClickSaved'))
} catch (e: any) {
chartClickEnabled.value = previous
message.error(e.message || t('display.chartClickFailed'))
} finally {
savingChartClick.value = false
}
}
/** /**
* 处理分类/渠道饼图扇区点击:分类按分类ID跳转,渠道按渠道编码跳转,无渠道编码的扇区不响应 * 处理分类/渠道饼图扇区点击:分类按分类ID跳转,渠道按渠道编码跳转,无渠道编码的扇区不响应
* *
@@ -606,6 +636,8 @@ function handleTrendClick(params: any) {
* :param e: zrender 点击事件(含相对画布的 offsetX/offsetY) * :param e: zrender 点击事件(含相对画布的 offsetX/offsetY)
*/ */
function handleTrendBlankClick(e: any) { function handleTrendBlankClick(e: any) {
// 关闭点击跳转后,整列空白点击不再响应
if (!chartClickEnabled.value) return
// 触屏端禁用「整列可点」,仅允许点击柱子本体,避免滑动/误触跳转 // 触屏端禁用「整列可点」,仅允许点击柱子本体,避免滑动/误触跳转
if (isTouch.value) return if (isTouch.value) return
// 命中柱子等图形时交给 handleTrendClick 处理,避免重复跳转 // 命中柱子等图形时交给 handleTrendClick 处理,避免重复跳转
@@ -895,6 +927,14 @@ watch(locale, () => {
updateIncomeChannelChart() updateIncomeChannelChart()
updateTrendChart() updateTrendChart()
}) })
// 点击跳转开关变化时重绘图表,同步光标等交互状态
watch(chartClickEnabled, () => {
updatePieChart()
updateIncomePieChart()
updateChannelChart()
updateIncomeChannelChart()
updateTrendChart()
})
onMounted(async () => { onMounted(async () => {
loadPrefs() loadPrefs()
@@ -902,6 +942,7 @@ onMounted(async () => {
loadChannels() loadChannels()
loadTags() loadTags()
loadAiConfig() loadAiConfig()
ensureChartClickEnabledLoaded()
// 从收支记录页跳转进入时,用共享筛选偏好覆盖面板筛选,并清除来源标记 // 从收支记录页跳转进入时,用共享筛选偏好覆盖面板筛选,并清除来源标记
if (route.query.from === 'transactions') { if (route.query.from === 'transactions') {
applySharedFilterPrefs() applySharedFilterPrefs()
@@ -1008,6 +1049,10 @@ onMounted(async () => {
/> />
<NInput v-model:value="filterRemark" clearable :placeholder="t('dashboard.remark')" style="width: 180px" /> <NInput v-model:value="filterRemark" clearable :placeholder="t('dashboard.remark')" style="width: 180px" />
<NButton @click="handleResetFilters">{{ t('dashboard.resetFilters') }}</NButton> <NButton @click="handleResetFilters">{{ t('dashboard.resetFilters') }}</NButton>
<NSpace align="center" :size="8">
<NSwitch :value="chartClickEnabled" :loading="savingChartClick" @update:value="handleChartClickToggle" />
<span style="color: #666; font-size: 13px">{{ t('dashboard.chartClickToggle') }}</span>
</NSpace>
</NSpace> </NSpace>
</NCard> </NCard>
@@ -1081,7 +1126,9 @@ onMounted(async () => {
<NSpace justify="space-between" align="center"> <NSpace justify="space-between" align="center">
<span> <span>
{{ t('dashboard.trend') }} {{ t('dashboard.trend') }}
<span style="color: #999; font-size: 12px; margin-left: 8px">{{ t('dashboard.trendClickHint') }}</span> <span v-if="chartClickEnabled" style="color: #999; font-size: 12px; margin-left: 8px">{{
t('dashboard.trendClickHint')
}}</span>
</span> </span>
<NSelect v-model:value="granularity" :options="granularityOptions" style="width: 110px" size="small" /> <NSelect v-model:value="granularity" :options="granularityOptions" style="width: 110px" size="small" />
</NSpace> </NSpace>
+2
View File
@@ -155,6 +155,8 @@ function cancelRestore() {
*/ */
function restoreScroll(path: string) { function restoreScroll(path: string) {
cancelRestore() cancelRestore()
// 目标路由带锚点(#)时由页面自身完成定位,跳过恢复以免覆盖锚点位置
if (route.hash) return
const target = readScroll(path) const target = readScroll(path)
const el = getScrollEl() const el = getScrollEl()
if (!el) return if (!el) return
+379 -13
View File
@@ -1,6 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
// 系统设置页:上下分区展示账号安全、数据库配置、注册设置、账单来源统计、AI 模型配置与定时通知 // 系统设置页:左侧设置目录 + 右侧分区面板;点击目录一键滚动定位,滚动联动高亮,并把当前分区同步到 URL 锚点
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import type { Component } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { NIcon } from 'naive-ui'
import {
KeyOutline,
ServerOutline,
PersonAddOutline,
OptionsOutline,
ColorPaletteOutline,
MenuOutline,
SettingsOutline,
NotificationsOutline,
} from '@vicons/ionicons5'
import AccountSecurityPanel from '../components/AccountSecurityPanel.vue' import AccountSecurityPanel from '../components/AccountSecurityPanel.vue'
import DatabaseConfigPanel from '../components/DatabaseConfigPanel.vue' import DatabaseConfigPanel from '../components/DatabaseConfigPanel.vue'
import RegistrationConfigPanel from '../components/RegistrationConfigPanel.vue' import RegistrationConfigPanel from '../components/RegistrationConfigPanel.vue'
@@ -9,37 +23,389 @@ import DisplayConfigPanel from '../components/DisplayConfigPanel.vue'
import NavMenuConfigPanel from '../components/NavMenuConfigPanel.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'
import { useBreakpoints } from '../composables/useBreakpoints'
const { t } = useI18n() const { t } = useI18n()
const route = useRoute()
const router = useRouter()
// 小屏/平板(主布局仍显示侧边栏,内容区较窄)时,目录改为顶部横向条目条
const { isMobile, isTablet } = useBreakpoints()
const compactNav = computed(() => isMobile.value || isTablet.value)
/** 设置分区配置:key 为 URL 友好的锚点 slug,labelKey 复用各面板标题文案 */
interface SettingsSection {
key: string
labelKey: string
icon: Component
component: Component
}
const sections: SettingsSection[] = [
{ key: 'account', labelKey: 'settings.accountSection', icon: KeyOutline, component: AccountSecurityPanel },
{ key: 'database', labelKey: 'settings.databaseSection', icon: ServerOutline, component: DatabaseConfigPanel },
{
key: 'registration',
labelKey: 'settings.registrationSection',
icon: PersonAddOutline,
component: RegistrationConfigPanel,
},
{
key: 'bill-source-stats',
labelKey: 'settings.billSourceStatsSection',
icon: OptionsOutline,
component: BillSourceStatsPanel,
},
{ key: 'display', labelKey: 'settings.displaySection', icon: ColorPaletteOutline, component: DisplayConfigPanel },
{ key: 'navigation', labelKey: 'settings.navSection', icon: MenuOutline, component: NavMenuConfigPanel },
{ key: 'ai', labelKey: 'settings.aiSection', icon: SettingsOutline, component: AiConfigPanel },
{
key: 'notification',
labelKey: 'settings.notificationSection',
icon: NotificationsOutline,
component: NotificationConfigPanel,
},
]
// 当前高亮分区,初始为第一项
const activeKey = ref(sections[0].key)
const contentRef = ref<HTMLElement | null>(null)
let scrollParent: HTMLElement | null = null
let scrollRaf = 0
// 显式锚定进行中:暂停滚动联动,内容高度变化(面板异步加载)时持续对齐;用户主动滚动后才恢复联动
let spySuspended = false
let pendingAnchorKey = ''
let resizeObserver: ResizeObserver | null = null
/** 生成分区锚点元素 id(与 URL hash 不同名,避免 vue-router 默认锚点滚动介入) */
function sectionElId(key: string): string {
return `settings-section-${key}`
}
/** 是否为合法的分区 slug */
function isSectionKey(value: string): boolean {
return sections.some((sec) => sec.key === value)
}
/** 当前 URL hash 指向的分区 slug,未命中时返回空串 */
function hashSectionKey(): string {
const key = route.hash.replace(/^#/, '')
return isSectionKey(key) ? key : ''
}
/** 将当前分区同步到 URL:点击用 push(可前进后退),滚动联动用 replace(不写入历史) */
function syncHash(key: string, mode: 'push' | 'replace') {
const hash = `#${key}`
if (route.hash === hash) return
const target = { path: route.path, query: route.query, hash }
if (mode === 'push') void router.push(target)
else void router.replace(target)
}
/** 向上查找最近的可滚动祖先:设置页滚动容器是 Layout 的内容区而非 window */
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
let node = el?.parentElement ?? null
while (node) {
if (/(auto|scroll|overlay)/.test(getComputedStyle(node).overflowY)) return node
node = node.parentElement
}
return null
}
/** 滚动定位到指定分区 */
function scrollToSection(key: string, behavior: 'auto' | 'smooth' = 'smooth') {
const el = document.getElementById(sectionElId(key))
if (!el) return
el.scrollIntoView({ behavior, block: 'start' })
}
/** 目录条目元素 id */
function navItemId(key: string): string {
return `settings-nav-${key}`
}
/** 让当前高亮条目滚入视野:仅横向滚动目录列表,避免影响内容区滚动(纵向目录无需滚动) */
function keepActiveVisible() {
nextTick(() => {
const item = document.getElementById(navItemId(activeKey.value))
const list = item?.parentElement
if (!item || !list || list.scrollWidth <= list.clientWidth) return
const itemRect = item.getBoundingClientRect()
const listRect = list.getBoundingClientRect()
const delta = itemRect.left - listRect.left - (listRect.width - itemRect.width) / 2
if (Math.abs(delta) > 1) list.scrollLeft += delta
})
}
/** 更新高亮并保证对应条目可见 */
function setActiveKey(key: string) {
activeKey.value = key
keepActiveVisible()
}
/** 开始锚定指定分区:暂停联动并记住目标,内容高度变化时持续对齐 */
function startAnchor(key: string) {
pendingAnchorKey = key
spySuspended = true
}
/** 内容高度变化(面板异步加载):锚定期间重新对齐目标分区,保证跳转位置稳定 */
function handleContentResize() {
if (spySuspended && pendingAnchorKey) {
scrollToSection(pendingAnchorKey, 'auto')
}
}
/** 用户主动滚动:解除锚定,恢复滚动联动(之后由 handleScroll 结算高亮与 URL) */
function releaseAnchor() {
if (!spySuspended) return
spySuspended = false
pendingAnchorKey = ''
}
/** 点击目录条目:即时高亮 + 写入 URL(产生历史,可后退)+ 平滑滚动并锚定到目标分区 */
function onNavClick(key: string) {
setActiveKey(key)
syncHash(key, 'push')
startAnchor(key)
scrollToSection(key, 'smooth')
}
/** 更新高亮:分区变化时同步写入 URL(replace,仅在变化时写,避免高频) */
function updateActive(key: string) {
if (activeKey.value === key) return
setActiveKey(key)
syncHash(key, 'replace')
}
/** 依据各分区相对滚动容器顶部的偏移计算当前分区(取最后一个已到达顶部的分区) */
function computeActive() {
if (!scrollParent) return
const { scrollTop, scrollHeight, clientHeight } = scrollParent
// 已滚动到底部时,末尾分区无法顶到容器顶部,直接高亮最后一项
if (scrollHeight - scrollTop - clientHeight <= 2) {
updateActive(sections[sections.length - 1].key)
return
}
const containerTop = scrollParent.getBoundingClientRect().top
let current = sections[0].key
for (const sec of sections) {
const el = document.getElementById(sectionElId(sec.key))
if (!el) continue
if (el.getBoundingClientRect().top - containerTop <= 12) current = sec.key
else break
}
updateActive(current)
}
/** 滚动事件:rAF 节流,更新高亮(锚定期间跳过) */
function handleScroll() {
if (spySuspended || scrollRaf) return
scrollRaf = requestAnimationFrame(() => {
scrollRaf = 0
computeActive()
})
}
/** 用户主动滚动(滚轮/触摸/方向键):解除锚定,把控制权交回滚动联动 */
function handleUserIntent(e: Event) {
if (e.type === 'keydown') {
const key = (e as KeyboardEvent).key
if (!['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(key)) return
}
releaseAnchor()
}
// URL hash 变化(浏览器前进/后退或外部深链)时,滚动定位到对应分区
watch(
() => route.hash,
(hash) => {
const key = hash.replace(/^#/, '')
if (!isSectionKey(key)) {
// 离开锚点(如后退到无 hash):解除锚定,按当前位置结算高亮
releaseAnchor()
computeActive()
return
}
if (key === activeKey.value) return
setActiveKey(key)
startAnchor(key)
nextTick(() => scrollToSection(key, 'smooth'))
},
)
onMounted(async () => {
await nextTick()
scrollParent = findScrollParent(contentRef.value)
scrollParent?.addEventListener('scroll', handleScroll, { passive: true })
// 用户主动滚动意图:解除锚定(滚轮/触摸在容器上,方向键在窗口上)
scrollParent?.addEventListener('wheel', handleUserIntent, { passive: true })
scrollParent?.addEventListener('touchmove', handleUserIntent, { passive: true })
window.addEventListener('keydown', handleUserIntent)
// 监听内容高度变化,用于锚定期间持续对齐(面板异步加载会改变分区位置)
if (contentRef.value) {
resizeObserver = new ResizeObserver(handleContentResize)
resizeObserver.observe(contentRef.value)
}
const key = hashSectionKey()
if (key) {
// 深链带锚点:即时高亮并锚定到该分区(面板加载引起高度变化时自动纠偏)
setActiveKey(key)
startAnchor(key)
scrollToSection(key, 'auto')
} else {
computeActive()
}
})
onBeforeUnmount(() => {
scrollParent?.removeEventListener('scroll', handleScroll)
scrollParent?.removeEventListener('wheel', handleUserIntent)
scrollParent?.removeEventListener('touchmove', handleUserIntent)
window.removeEventListener('keydown', handleUserIntent)
if (scrollRaf) cancelAnimationFrame(scrollRaf)
resizeObserver?.disconnect()
})
</script> </script>
<template> <template>
<div class="settings-page"> <div class="settings-page">
<h2 class="page-title">{{ t('settings.title') }}</h2> <h2 class="page-title">{{ t('settings.title') }}</h2>
<AccountSecurityPanel /> <div class="settings-layout" :class="{ 'settings-layout--mobile': compactNav }">
<DatabaseConfigPanel /> <!-- 设置目录:桌面左侧竖排,小屏/平板顶部横向条目条 -->
<RegistrationConfigPanel /> <nav class="settings-nav" :class="{ 'settings-nav--mobile': compactNav }" :aria-label="t('settings.navTitle')">
<BillSourceStatsPanel /> <div class="settings-nav__title">{{ t('settings.navTitle') }}</div>
<DisplayConfigPanel /> <div class="settings-nav__list">
<NavMenuConfigPanel /> <button
<AiConfigPanel /> v-for="sec in sections"
<NotificationConfigPanel /> :id="navItemId(sec.key)"
:key="sec.key"
type="button"
class="settings-nav__item"
:class="{ 'settings-nav__item--active': activeKey === sec.key }"
@click="onNavClick(sec.key)"
>
<NIcon :component="sec.icon" />
<span class="settings-nav__label">{{ t(sec.labelKey) }}</span>
</button>
</div>
</nav>
<!-- 分区面板:纵向堆叠,作为滚动定位锚点 -->
<div ref="contentRef" class="settings-content">
<section v-for="sec in sections" :id="sectionElId(sec.key)" :key="sec.key" class="settings-section">
<component :is="sec.component" />
</section>
</div>
</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.settings-page { .settings-page {
max-width: 860px; max-width: 1064px;
}
.page-title {
margin: 0 0 20px;
}
.settings-layout {
display: grid;
grid-template-columns: 200px 1fr;
gap: 24px;
align-items: start;
}
/* 设置目录:桌面端吸附在内容区顶部 */
.settings-nav {
position: sticky;
top: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.settings-nav__title {
padding: 0 10px 4px;
font-size: 12px;
color: #999;
}
.settings-nav__list {
display: flex;
flex-direction: column;
gap: 2px;
}
.settings-nav__item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: #333;
font-size: 13px;
text-align: left;
cursor: pointer;
transition:
background-color 0.2s,
color 0.2s;
}
/* 悬停高亮排除激活项:避免 :hover 特异性高于 --active 而盖掉绿色选中态 */
.settings-nav__item:hover:not(.settings-nav__item--active) {
background: #f5f6f8;
}
.settings-nav__item--active {
background: rgba(24, 160, 88, 0.12);
color: #18a058;
font-weight: 600;
}
.settings-nav__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 20px;
} }
.page-title { .settings-section {
margin: 0; scroll-margin-top: 12px;
} }
.settings-page > :deep(.n-card) { .settings-content :deep(.n-card) {
border: 1px solid #efeff5; border: 1px solid #efeff5;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
} }
/* 小屏:单列,目录切换为顶部吸附的横向可滑动条目条 */
.settings-layout--mobile {
display: block;
}
.settings-nav--mobile {
position: sticky;
top: 0;
z-index: 5;
margin: 0 -12px 16px;
padding: 8px 12px;
background: #fff;
border-bottom: 1px solid #efeff5;
}
.settings-nav--mobile .settings-nav__title {
display: none;
}
.settings-nav--mobile .settings-nav__list {
flex-direction: row;
gap: 8px;
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
}
.settings-nav--mobile .settings-nav__item {
width: auto;
flex: 0 0 auto;
background: #f5f6f8;
}
.settings-nav--mobile .settings-nav__item--active {
background: rgba(24, 160, 88, 0.12);
}
</style> </style>
@@ -12,6 +12,7 @@ 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.ChartClickNavigationRequest;
import cn.rosercode.ledgerly.dto.ExcludedDisplayStyleRequest; import cn.rosercode.ledgerly.dto.ExcludedDisplayStyleRequest;
import cn.rosercode.ledgerly.dto.NavMenuPrefsRequest; import cn.rosercode.ledgerly.dto.NavMenuPrefsRequest;
import cn.rosercode.ledgerly.dto.PaginationPrefsRequest; import cn.rosercode.ledgerly.dto.PaginationPrefsRequest;
@@ -116,6 +117,32 @@ public class UserSettingController {
return Result.success(userPreferenceService.getShowHiddenData(userId)); return Result.success(userPreferenceService.getShowHiddenData(userId));
} }
/**
* 查询当前用户是否启用统计面板图表的点击跳转明细,未配置时默认开启。
*
* @return true-启用点击跳转,false-关闭点击跳转
*/
@GetMapping("/chart-click-navigation")
@Operation(summary = "查询图表点击跳转偏好", description = "读取当前用户是否启用统计面板图表的点击跳转明细,未配置时默认开启")
public Result<Boolean> getChartClickNavigation() {
return Result.success(userPreferenceService.getChartClickNavigation(UserContext.getUserId()));
}
/**
* 更新当前用户是否启用统计面板图表的点击跳转明细。
*
* @param request 图表点击跳转偏好更新请求
* @return 更新后的图表点击跳转偏好
*/
@PutMapping("/chart-click-navigation")
@Operation(summary = "更新图表点击跳转偏好", description = "设置当前用户是否启用统计面板图表的点击跳转明细")
public Result<Boolean> updateChartClickNavigation(@RequestBody ChartClickNavigationRequest request) {
Long userId = UserContext.getUserId();
boolean enabled = !Boolean.FALSE.equals(request.getEnabled());
userPreferenceService.saveChartClickNavigation(userId, enabled);
return Result.success(userPreferenceService.getChartClickNavigation(userId));
}
/** /**
* 查询当前用户配置的表格分页偏好。 * 查询当前用户配置的表格分页偏好。
* *
@@ -0,0 +1,15 @@
package cn.rosercode.ledgerly.dto;
import lombok.Data;
/**
* 「图表点击跳转明细」偏好更新请求对象,控制统计面板图表元素点击后是否跳转收支记录页。
*
* @date 2026-10-13 12:50:04
*/
@Data
public class ChartClickNavigationRequest {
/** 是否启用图表点击跳转明细(默认开启) */
private Boolean enabled;
}
@@ -60,6 +60,22 @@ public interface UserPreferenceService {
*/ */
void saveShowHiddenData(Long userId, boolean show); void saveShowHiddenData(Long userId, boolean show);
/**
* 获取当前用户是否启用统计面板图表的点击跳转明细,未配置时回退默认值(开启)。
*
* @param userId 用户ID
* @return true-启用点击跳转,false-关闭点击跳转
*/
boolean getChartClickNavigation(Long userId);
/**
* 保存当前用户是否启用统计面板图表的点击跳转明细。
*
* @param userId 用户ID
* @param enabled 是否启用点击跳转
*/
void saveChartClickNavigation(Long userId, boolean enabled);
/** /**
* 获取当前用户配置的表格分页偏好(每页条数候选列表与默认每页条数),未配置或非法时回退默认值。 * 获取当前用户配置的表格分页偏好(每页条数候选列表与默认每页条数),未配置或非法时回退默认值。
* *
@@ -53,6 +53,9 @@ public class UserPreferenceServiceImpl implements UserPreferenceService {
/** 偏好键:侧边导航菜单偏好(顺序与显隐) */ /** 偏好键:侧边导航菜单偏好(顺序与显隐) */
private static final String KEY_NAV_MENU = "navMenu"; private static final String KEY_NAV_MENU = "navMenu";
/** 偏好键:统计面板图表点击是否跳转明细 */
private static final String KEY_CHART_CLICK_NAVIGATION = "chartClickNavigation";
/** 导航菜单项数量上限,避免异常数据无限增长 */ /** 导航菜单项数量上限,避免异常数据无限增长 */
private static final int MAX_NAV_MENU_ITEMS = 100; private static final int MAX_NAV_MENU_ITEMS = 100;
@@ -177,6 +180,31 @@ public class UserPreferenceServiceImpl implements UserPreferenceService {
log.info("保存隐藏备注记录显示偏好: userId={}, show={}", userId, show); log.info("保存隐藏备注记录显示偏好: userId={}, show={}", userId, show);
} }
/**
* {@inheritDoc}
*
* 结果按用户缓存,未配置时默认开启点击跳转。
*/
@Override
@Cacheable(cacheNames = CacheConfig.USER_PREFERENCE_CACHE, key = "'chartClickNavigation:' + #userId")
public boolean getChartClickNavigation(Long userId) {
String value = findValue(userId, KEY_CHART_CLICK_NAVIGATION);
// 未配置时默认开启,仅显式为 "false" 时才关闭
return value == null || Boolean.parseBoolean(value);
}
/**
* {@inheritDoc}
*
* 保存后失效该用户的图表点击跳转缓存。
*/
@Override
@CacheEvict(cacheNames = CacheConfig.USER_PREFERENCE_CACHE, key = "'chartClickNavigation:' + #userId")
public void saveChartClickNavigation(Long userId, boolean enabled) {
upsert(userId, KEY_CHART_CLICK_NAVIGATION, Boolean.toString(enabled));
log.info("保存图表点击跳转偏好: userId={}, enabled={}", userId, enabled);
}
/** /**
* {@inheritDoc} * {@inheritDoc}
* *