From bfd314a38e4e8e16f7eb4d6ea841124e034e98b6 Mon Sep 17 00:00:00 2001 From: rosercode Date: Tue, 13 Oct 2026 12:54:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AE=BE=E7=BD=AE=E9=A1=B5=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=88=86=E5=8C=BA=E7=9B=AE=E5=BD=95=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81=20URL=20=E9=94=9A=E7=82=B9=EF=BC=8C?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E9=9D=A2=E6=9D=BF=E5=9B=BE=E8=A1=A8=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E8=B7=B3=E8=BD=AC=E5=8F=AF=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设置页由单一长列表改为「目录 + 分区」结构:桌面左侧吸附目录、小屏/平板顶部横向条目条,点击可平滑定位并同步 URL 锚点(支持 /settings# 深链与浏览器前进后退);同时为统计面板图表补充用户级「点击跳转明细」开关(默认开启),面板与设置页两处入口实时联动。 变更内容: - 设置页目录导航: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;未配置默认开启,老用户无感知。 --- frontend/src/api/setting.ts | 10 + .../src/components/DisplayConfigPanel.vue | 32 ++ .../composables/useChartClickNavigation.ts | 35 ++ frontend/src/locales/messages.ts | 26 ++ frontend/src/router/index.ts | 5 + frontend/src/views/Dashboard.vue | 57 ++- frontend/src/views/Layout.vue | 2 + frontend/src/views/Settings.vue | 392 +++++++++++++++++- .../controller/UserSettingController.java | 27 ++ .../dto/ChartClickNavigationRequest.java | 15 + .../service/UserPreferenceService.java | 16 + .../impl/UserPreferenceServiceImpl.java | 28 ++ 12 files changed, 627 insertions(+), 18 deletions(-) create mode 100644 frontend/src/composables/useChartClickNavigation.ts create mode 100644 src/main/java/cn/rosercode/ledgerly/dto/ChartClickNavigationRequest.java diff --git a/frontend/src/api/setting.ts b/frontend/src/api/setting.ts index 077e783..9eeb447 100644 --- a/frontend/src/api/setting.ts +++ b/frontend/src/api/setting.ts @@ -31,6 +31,16 @@ export function updateShowHiddenData(show: boolean) { 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() { return request.get('/setting/pagination-prefs') diff --git a/frontend/src/components/DisplayConfigPanel.vue b/frontend/src/components/DisplayConfigPanel.vue index 1047c0b..8fb88fc 100644 --- a/frontend/src/components/DisplayConfigPanel.vue +++ b/frontend/src/components/DisplayConfigPanel.vue @@ -11,8 +11,10 @@ import { updateShowHiddenData, getPaginationPrefs, updatePaginationPrefs, + updateChartClickNavigation, } from '../api/setting' import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE_OPTIONS, loadPageSizeOptions } from '../composables/usePageSizeOptions' +import { chartClickEnabled, ensureChartClickEnabledLoaded } from '../composables/useChartClickNavigation' const message = useMessage() const { t } = useI18n() @@ -26,6 +28,9 @@ const showHiddenData = ref(false) const loadingHidden = ref(false) const savingHidden = ref(false) +/** 图表点击跳转偏好:与统计面板共享全局状态 */ +const savingChartClick = ref(false) + /** 表格分页每页条数候选列表(字符串标签形式,提交时解析为数字) */ const pageSizeTags = ref([]) /** 默认每页条数 */ @@ -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) { syncingFromServer = true @@ -183,6 +206,7 @@ onMounted(() => { loadStyle() loadHiddenData() loadPageSizes() + ensureChartClickEnabledLoaded() }) @@ -242,6 +266,14 @@ onMounted(() => { +
+
{{ t('display.chartClickLabel') }}
+ + + {{ t('display.chartClickHint') }} + +
+
{{ t('display.pageSizeLabel') }}
{{ diff --git a/frontend/src/composables/useChartClickNavigation.ts b/frontend/src/composables/useChartClickNavigation.ts new file mode 100644 index 0000000..b813e9c --- /dev/null +++ b/frontend/src/composables/useChartClickNavigation.ts @@ -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 +} diff --git a/frontend/src/locales/messages.ts b/frontend/src/locales/messages.ts index 4d0b2d8..081d689 100644 --- a/frontend/src/locales/messages.ts +++ b/frontend/src/locales/messages.ts @@ -213,6 +213,7 @@ export default { // 系统设置 settings: { title: { zh: '设置', en: 'Settings', ja: '設定' }, + navTitle: { zh: '设置目录', en: 'Contents', ja: '目次' }, accountSection: { zh: '账号安全', en: 'Account Security', ja: 'アカウントセキュリティ' }, databaseSection: { zh: '数据库配置', en: 'Database', ja: 'データベース' }, aiSection: { zh: 'AI 模型配置', en: 'AI Model', ja: 'AI モデル' }, @@ -352,6 +353,26 @@ export default { en: 'Failed to update hidden remark record display', 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ページ件数オプション' }, pageSizeHint: { zh: '所有表格分页共用的每页条数候选列表,修改后自动保存;未配置时使用默认 10 / 20 / 50 / 100', @@ -708,6 +729,11 @@ export default { en: 'Click a bar to view matching records', ja: '棒をクリックすると該当明細を表示', }, + chartClickToggle: { + zh: '点击图表查看明细', + en: 'Click chart to view records', + ja: 'グラフをクリックして明細表示', + }, expenseCategoryPie: { zh: '支出分类占比', en: 'Expense by Category', ja: '支出カテゴリの割合' }, incomeCategoryPie: { zh: '收入分类占比', en: 'Income by Category', ja: '収入カテゴリの割合' }, expenseChannelPie: { zh: '支出交易渠道占比', en: 'Expense by Channel', ja: '支出の取引チャネル割合' }, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 1976481..349682c 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -125,6 +125,11 @@ const router = createRouter({ // 全局前置守卫:优先校验数据库配置,再校验登录状态 router.beforeEach(async (to, _from, next) => { + // 仅 hash/query 变化(路径不变)时无需重复执行数据库与登录校验,直接放行 + if (to.path === _from.path) { + next() + return + } const databaseStore = useDatabaseStore() // 首次进入时从后端加载数据库配置状态 if (!databaseStore.loaded) { diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 9e0aded..e7ffd95 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -15,6 +15,7 @@ import { NInputNumber, NButton, NIcon, + NSwitch, useMessage, } from 'naive-ui' import { SparklesOutline } from '@vicons/ionicons5' @@ -31,6 +32,8 @@ import { STORAGE_KEYS } from '../utils/storageKeys' import { loadTransactionFilterPrefs, saveTransactionFilterPrefs } from '../utils/transactionFilterPrefs' import AiQueryWindow from '../components/AiQueryWindow.vue' import { useBreakpoints } from '../composables/useBreakpoints' +import { updateChartClickNavigation } from '../api/setting' +import { chartClickEnabled, ensureChartClickEnabledLoaded } from '../composables/useChartClickNavigation' // 小屏适配:图表高度随断点降低,避免在窄屏占用过多纵向空间 const { isMobile, isTouch, viewportWidth } = useBreakpoints() @@ -285,7 +288,7 @@ function updatePieChart() { { type: 'pie', radius: ['40%', '70%'], - cursor: 'pointer', + cursor: chartClickEnabled.value ? 'pointer' : 'default', data: categoryData.value .filter((c: any) => c.type === 'EXPENSE') .map((c: any) => ({ @@ -308,7 +311,7 @@ function updateIncomePieChart() { { type: 'pie', radius: ['40%', '70%'], - cursor: 'pointer', + cursor: chartClickEnabled.value ? 'pointer' : 'default', data: categoryData.value .filter((c: any) => c.type === 'INCOME') .map((c: any) => ({ @@ -331,7 +334,7 @@ function updateChannelChart() { { type: 'pie', radius: ['40%', '70%'], - cursor: 'pointer', + cursor: chartClickEnabled.value ? 'pointer' : 'default', data: channelData.value .filter((p: any) => Number(p.expense) > 0) .map((p: any) => ({ @@ -354,7 +357,7 @@ function updateIncomeChannelChart() { { type: 'pie', radius: ['40%', '70%'], - cursor: 'pointer', + cursor: chartClickEnabled.value ? 'pointer' : 'default', data: channelData.value .filter((p: any) => Number(p.income) > 0) .map((p: any) => ({ @@ -542,6 +545,8 @@ const chartActionStyle = computed(() => { * :param action: 实际执行跳转的回调 */ function triggerChartNavigation(params: any, action: () => void) { + // 关闭点击跳转后,图表元素不再响应点击 + if (!chartClickEnabled.value) return if (!isTouch.value) { action() return @@ -565,6 +570,31 @@ function closeChartAction() { 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跳转,渠道按渠道编码跳转,无渠道编码的扇区不响应 * @@ -606,6 +636,8 @@ function handleTrendClick(params: any) { * :param e: zrender 点击事件(含相对画布的 offsetX/offsetY) */ function handleTrendBlankClick(e: any) { + // 关闭点击跳转后,整列空白点击不再响应 + if (!chartClickEnabled.value) return // 触屏端禁用「整列可点」,仅允许点击柱子本体,避免滑动/误触跳转 if (isTouch.value) return // 命中柱子等图形时交给 handleTrendClick 处理,避免重复跳转 @@ -895,6 +927,14 @@ watch(locale, () => { updateIncomeChannelChart() updateTrendChart() }) +// 点击跳转开关变化时重绘图表,同步光标等交互状态 +watch(chartClickEnabled, () => { + updatePieChart() + updateIncomePieChart() + updateChannelChart() + updateIncomeChannelChart() + updateTrendChart() +}) onMounted(async () => { loadPrefs() @@ -902,6 +942,7 @@ onMounted(async () => { loadChannels() loadTags() loadAiConfig() + ensureChartClickEnabledLoaded() // 从收支记录页跳转进入时,用共享筛选偏好覆盖面板筛选,并清除来源标记 if (route.query.from === 'transactions') { applySharedFilterPrefs() @@ -1008,6 +1049,10 @@ onMounted(async () => { /> {{ t('dashboard.resetFilters') }} + + + {{ t('dashboard.chartClickToggle') }} + @@ -1081,7 +1126,9 @@ onMounted(async () => { {{ t('dashboard.trend') }} - {{ t('dashboard.trendClickHint') }} + {{ + t('dashboard.trendClickHint') + }} diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue index bc1f128..7b53469 100644 --- a/frontend/src/views/Layout.vue +++ b/frontend/src/views/Layout.vue @@ -155,6 +155,8 @@ function cancelRestore() { */ function restoreScroll(path: string) { cancelRestore() + // 目标路由带锚点(#)时由页面自身完成定位,跳过恢复以免覆盖锚点位置 + if (route.hash) return const target = readScroll(path) const el = getScrollEl() if (!el) return diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue index 045c937..95b55a9 100644 --- a/frontend/src/views/Settings.vue +++ b/frontend/src/views/Settings.vue @@ -1,6 +1,20 @@ diff --git a/src/main/java/cn/rosercode/ledgerly/controller/UserSettingController.java b/src/main/java/cn/rosercode/ledgerly/controller/UserSettingController.java index f27193c..8130c24 100644 --- a/src/main/java/cn/rosercode/ledgerly/controller/UserSettingController.java +++ b/src/main/java/cn/rosercode/ledgerly/controller/UserSettingController.java @@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.RestController; import cn.rosercode.ledgerly.common.Result; import cn.rosercode.ledgerly.dto.BillSourceStatsRequest; +import cn.rosercode.ledgerly.dto.ChartClickNavigationRequest; import cn.rosercode.ledgerly.dto.ExcludedDisplayStyleRequest; import cn.rosercode.ledgerly.dto.NavMenuPrefsRequest; import cn.rosercode.ledgerly.dto.PaginationPrefsRequest; @@ -116,6 +117,32 @@ public class UserSettingController { return Result.success(userPreferenceService.getShowHiddenData(userId)); } + /** + * 查询当前用户是否启用统计面板图表的点击跳转明细,未配置时默认开启。 + * + * @return true-启用点击跳转,false-关闭点击跳转 + */ + @GetMapping("/chart-click-navigation") + @Operation(summary = "查询图表点击跳转偏好", description = "读取当前用户是否启用统计面板图表的点击跳转明细,未配置时默认开启") + public Result getChartClickNavigation() { + return Result.success(userPreferenceService.getChartClickNavigation(UserContext.getUserId())); + } + + /** + * 更新当前用户是否启用统计面板图表的点击跳转明细。 + * + * @param request 图表点击跳转偏好更新请求 + * @return 更新后的图表点击跳转偏好 + */ + @PutMapping("/chart-click-navigation") + @Operation(summary = "更新图表点击跳转偏好", description = "设置当前用户是否启用统计面板图表的点击跳转明细") + public Result 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)); + } + /** * 查询当前用户配置的表格分页偏好。 * diff --git a/src/main/java/cn/rosercode/ledgerly/dto/ChartClickNavigationRequest.java b/src/main/java/cn/rosercode/ledgerly/dto/ChartClickNavigationRequest.java new file mode 100644 index 0000000..3ab5f20 --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/dto/ChartClickNavigationRequest.java @@ -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; +} diff --git a/src/main/java/cn/rosercode/ledgerly/service/UserPreferenceService.java b/src/main/java/cn/rosercode/ledgerly/service/UserPreferenceService.java index b508974..d744bbd 100644 --- a/src/main/java/cn/rosercode/ledgerly/service/UserPreferenceService.java +++ b/src/main/java/cn/rosercode/ledgerly/service/UserPreferenceService.java @@ -60,6 +60,22 @@ public interface UserPreferenceService { */ 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); + /** * 获取当前用户配置的表格分页偏好(每页条数候选列表与默认每页条数),未配置或非法时回退默认值。 * diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/UserPreferenceServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/UserPreferenceServiceImpl.java index df25c73..f21ecec 100644 --- a/src/main/java/cn/rosercode/ledgerly/service/impl/UserPreferenceServiceImpl.java +++ b/src/main/java/cn/rosercode/ledgerly/service/impl/UserPreferenceServiceImpl.java @@ -53,6 +53,9 @@ public class UserPreferenceServiceImpl implements UserPreferenceService { /** 偏好键:侧边导航菜单偏好(顺序与显隐) */ 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; @@ -177,6 +180,31 @@ public class UserPreferenceServiceImpl implements UserPreferenceService { 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} *