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:
@@ -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')
|
||||
|
||||
@@ -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<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) {
|
||||
syncingFromServer = true
|
||||
@@ -183,6 +206,7 @@ onMounted(() => {
|
||||
loadStyle()
|
||||
loadHiddenData()
|
||||
loadPageSizes()
|
||||
ensureChartClickEnabledLoaded()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -242,6 +266,14 @@ onMounted(() => {
|
||||
</NSpace>
|
||||
</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="group-title">{{ t('display.pageSizeLabel') }}</div>
|
||||
<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
|
||||
}
|
||||
@@ -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: '支出の取引チャネル割合' },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
/>
|
||||
<NInput v-model:value="filterRemark" clearable :placeholder="t('dashboard.remark')" style="width: 180px" />
|
||||
<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>
|
||||
</NCard>
|
||||
|
||||
@@ -1081,7 +1126,9 @@ onMounted(async () => {
|
||||
<NSpace justify="space-between" align="center">
|
||||
<span>
|
||||
{{ 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>
|
||||
<NSelect v-model:value="granularity" :options="granularityOptions" style="width: 110px" size="small" />
|
||||
</NSpace>
|
||||
|
||||
@@ -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
|
||||
|
||||
+379
-13
@@ -1,6 +1,20 @@
|
||||
<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 { 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 DatabaseConfigPanel from '../components/DatabaseConfigPanel.vue'
|
||||
import RegistrationConfigPanel from '../components/RegistrationConfigPanel.vue'
|
||||
@@ -9,37 +23,389 @@ import DisplayConfigPanel from '../components/DisplayConfigPanel.vue'
|
||||
import NavMenuConfigPanel from '../components/NavMenuConfigPanel.vue'
|
||||
import AiConfigPanel from '../components/AiConfigPanel.vue'
|
||||
import NotificationConfigPanel from '../components/NotificationConfigPanel.vue'
|
||||
import { useBreakpoints } from '../composables/useBreakpoints'
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<h2 class="page-title">{{ t('settings.title') }}</h2>
|
||||
<AccountSecurityPanel />
|
||||
<DatabaseConfigPanel />
|
||||
<RegistrationConfigPanel />
|
||||
<BillSourceStatsPanel />
|
||||
<DisplayConfigPanel />
|
||||
<NavMenuConfigPanel />
|
||||
<AiConfigPanel />
|
||||
<NotificationConfigPanel />
|
||||
<div class="settings-layout" :class="{ 'settings-layout--mobile': compactNav }">
|
||||
<!-- 设置目录:桌面左侧竖排,小屏/平板顶部横向条目条 -->
|
||||
<nav class="settings-nav" :class="{ 'settings-nav--mobile': compactNav }" :aria-label="t('settings.navTitle')">
|
||||
<div class="settings-nav__title">{{ t('settings.navTitle') }}</div>
|
||||
<div class="settings-nav__list">
|
||||
<button
|
||||
v-for="sec in sections"
|
||||
: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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.page-title {
|
||||
margin: 0;
|
||||
.settings-section {
|
||||
scroll-margin-top: 12px;
|
||||
}
|
||||
.settings-page > :deep(.n-card) {
|
||||
.settings-content :deep(.n-card) {
|
||||
border: 1px solid #efeff5;
|
||||
border-radius: 12px;
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user