# 前端用户偏好与持久化 本文梳理前端「用户偏好」的保存逻辑:**为什么保存、保存了哪些偏好、存在哪里、何时读写、目的是什么**。 ## 为什么保存 汇账的不少界面是「多 Tab」或「带筛选条件」的,如果每次进入都重置为默认值,用户每次都要重新选一遍语言、筛选项、分页、排序、上次使用的记账方式等,体验割裂。 为此项目在 `ledgerly/AGENTS.md` 中约定: > 多 Tab 或可切换的界面偏好需**持久化记忆**(`localStorage`),下次打开时恢复到上次选择;存储键统一以 `ledgerly.` 前缀命名,读取时对非法值回退默认项,写入时忽略存储异常。 据此形成三条实践原则: 1. **进入页面即还原**:`onMounted` 或模块初始化时读取上次选择。 2. **变化即保存**:用 `watch` 监听偏好变量,任一变化自动写入,无需用户点「保存」。 3. **容错**:读取时校验取值白名单,非法/损坏数据回退默认;写入包在 `try/catch` 中,忽略隐私模式等存储异常。 偏好分三层存储: | 存储介质 | 生命周期 | 适用场景 | |---|---|---| | `localStorage` | 跨会话、跨标签页 | 语言、筛选条件、Tab、历史记录等界面偏好 | | `sessionStorage` | 仅当前浏览器会话 | 内容区滚动位置(刷新/切换恢复) | | 后端(按当前用户) | 跟随账号 | 需要跨设备/多端一致的外观与功能配置 | > 登录凭证 `token` 也存放在 `localStorage`,但它属于**认证状态**而非界面偏好,见文末说明。 ## 总览表 ### localStorage 偏好 | 键名 | 偏好内容 | 文件 | 保存时机 | 读取时机 | |---|---|---|---|---| | `ledgerly.locale` | 界面语言 `zh-CN`/`en-US`/`ja-JP` | `locales/index.ts` | 切换语言 | 模块初始化 | | `ledgerly.calendar.view` | 日历视图 `month`/`year` | `views/Calendar.vue` | `useStringPreference` 自动 | 组件初始化 | | `ledgerly.calendar.year` | 日历最近浏览年份(四位数字符串) | `views/Calendar.vue` | 变量 `watch` 自动 | 组件初始化 | | `ledgerly.calendar.month` | 日历月视图最近浏览月份(`1`-`12`) | `views/Calendar.vue` | 变量 `watch` 自动 | 组件初始化 | | `ledgerly.calendar.statsScope` | 日历统计口径 `INCLUDED`/`ALL`/`EXCLUDED` | `views/Calendar.vue` | `useStringPreference` 自动 | 组件初始化 | | `ledgerly.dashboard.prefs` | 统计面板时间范围、粒度与多项筛选 | `views/Dashboard.vue` | 变量 `watch` 自动 | `onMounted` | | `ledgerly.transaction.filterPrefs` | 收支列表筛选、分页、排序 | `views/TransactionList.vue` | 变量 `watch` 自动 | `onMounted` | | `ledgerly.transaction.aiScope` | 批量 AI 建议范围 | `views/TransactionList.vue` | 变量 `watch` 自动 | ref 初始化 | | `ledgerly.transaction.aiRecordTab` | AI 记账方式(文字/图片) | `views/TransactionList.vue` | 变量 `watch` 自动 | ref 初始化 | | `ledgerly.transaction.aiQueryHistory` | AI 自然语言查询历史 | `views/TransactionList.vue` / `components/AiQueryWindow.vue` | 提交查询时 | 浮窗聚焦时 | | `ledgerly.dashboard.aiSearchHistory` | 统计面板 AI 查询历史 | `views/Dashboard.vue` | 提交 AI 搜索时 | 浮窗聚焦时 | | `ledgerly.transaction.aiQueryWindow` | 收支记录 AI 查询浮窗位置与尺寸 | `components/FloatingPanel.vue` | 拖拽/缩放结束时 | 浮窗初始化 | | `ledgerly.dashboard.aiQueryWindow` | 统计面板 AI 搜索浮窗位置与尺寸 | `components/FloatingPanel.vue` | 拖拽/缩放结束时 | 浮窗初始化 | | `ledgerly.transaction.remarkHistory` | 备注关键词历史 | `views/TransactionList.vue` | 备注失焦/回车 | `onMounted` | | `ledgerly.billImport.billType` | 上次选择的账单来源类型 | `views/BillImportPanel.vue` | 切换类型时 | ref 初始化 | | `ledgerly.transaction.relationType` | 账单关系类型偏好 | `components/TransactionRelationEditorModal.vue` | 创建模式切换类型时 | 弹窗初始化 | | `ledgerly.pagination.tablePageSizes` | 各表格分别记住的每页条数 | `utils/tablePageSizePrefs.ts` | 切换每页条数时 | 表格初始化 | | `ledgerly.emailBill.tab` | 邮箱账单页标签页 | `views/EmailBillManage.vue` | Tab 切换时 | 路由重定向 + 组件 | | `ledgerly.logs.tab` | 日志页标签页 | `views/Logs.vue` | Tab 切换时 | 路由重定向 + 组件 | > `locale`、`emailBill.tab`、`logs.tab` 是较早的实现,最初未加前缀,现已统一为 `ledgerly.` 前缀:在 `LEGACY_STORAGE_KEYS` 登记旧键、读取前用 `migrateKey` 一次性迁移;品牌改名(`finance.` → `ledgerly.`)的旧键由 `migrateLegacyPrefixedKeys()` 在模块加载时批量迁移,新键已存在时不覆盖。 ### sessionStorage 偏好 | 键名 | 偏好内容 | 文件 | |---|---|---| | `ledgerly.scroll.<路由路径>` | 各页面内容区滚动位置 | `views/Layout.vue` | | `ledgerly.sqlQuery.session` | 智能查数当前会话的提问、结果与视图(结果超 500 行时只存提问与视图) | `views/SqlQuery.vue` | ### 后端按用户同步的偏好 | 接口 | 偏好内容 | 前端入口 | |---|---|---| | `GET/PUT /api/setting/excluded-display-style` | 「不计入统计」记录展示样式 | `components/DisplayConfigPanel.vue` | | `GET/PUT /api/setting/show-hidden-data` | 是否显示已隐藏备注的记录 | `components/DisplayConfigPanel.vue` | | `GET/PUT /api/setting/pagination-prefs` | 表格分页候选与默认每页条数 | `components/DisplayConfigPanel.vue` | | `GET/PUT /api/setting/nav-menu` | 左侧导航顺序与显隐 | `components/NavMenuConfigPanel.vue` | | `GET/PUT /api/setting/bill-source-include-in-stats` | 各账单来源「计入统计」默认值 | `components/BillSourceStatsPanel.vue` | | `GET/PUT /api/ai/config` | AI 启用开关、密钥、模型 | `components/AiConfigPanel.vue` | ## localStorage 偏好详解 ### 界面语言 `ledgerly.locale` - **文件**:`frontend/src/locales/index.ts` - **取值**:`zh-CN`、`en-US`、`ja-JP` - **读取**:`resolveInitialLocale()`(第 42 行)按「`localStorage` → `navigator.language` → `zh-CN`」优先级解析,作为 `createI18n` 初始语言。 - **保存**:`setLocale()`(第 75 行)在切换语言时写入,并同步 `document.documentElement.lang`。 - **目的**:记住用户语言选择,应用启动与切换即时生效。 - **入口**:`components/LocaleSwitch.vue`。 ### 日历偏好 `ledgerly.calendar.view` / `ledgerly.calendar.year` / `ledgerly.calendar.month` / `ledgerly.calendar.statsScope` - **文件**:`frontend/src/views/Calendar.vue` - **取值**: - `ledgerly.calendar.view`:`month`(月视图,默认)/ `year`(年视图);非法值回退 `month`。 - `ledgerly.calendar.year`:最近浏览的年份(四位数字符串),月视图面板年份与年视图年份共用;非法值回退当前年。 - `ledgerly.calendar.month`:月视图最近浏览的月份(`1`-`12`);非法值回退当前月。 - `ledgerly.calendar.statsScope`:`INCLUDED`(仅计入统计,默认)/ `ALL`(全部记录)/ `EXCLUDED`(仅不计入);非法值回退 `INCLUDED`。 - **读取**:组件初始化时经 `useStringPreference` 读取;URL 中的 `view`/`y`/`m`(如 `?view=year&y=2026`)优先于偏好,无参数时按偏好恢复上次视图、年月与统计口径。 - **保存**:`view`/`statsScope` 由 `useStringPreference` 内部 `watch` 自动写入;`year`/`month` 由组件内 `watch(panelYear/panelMonth)`(月视图)与 `watch(yearViewYear)`(年视图)写入。 - **目的**:下次进入日历自动还原上次的月/年视图、年月与统计口径。 ### 统计面板偏好 `ledgerly.dashboard.prefs` - **文件**:`frontend/src/views/Dashboard.vue`(键名第 470 行) - **数据结构**(JSON 对象,保存见 `savePrefs()` 第 499 行): | 字段 | 含义 | 默认值 | |---|---|---| | `rangeType` | 时间范围 `month`/`3month`/`12month`/`custom` | `month` | | `customRange` | 自定义起止时间戳 `[number, number]` | `null` | | `granularity` | 趋势粒度 `day`/`week`/`month`/`quarter`/`year` | `day` | | `filterStatsScope` | 统计口径 `INCLUDED`/`ALL`/`EXCLUDED` | `INCLUDED` | | `filterType` | 收支类型(多选) | `[]` | | `filterCategory` | 分类(多选,`0` 表示未分类) | `[]` | | `filterChannel` | 交易渠道(多选) | `[]` | | `filterTag` | 标签(多选) | `[]` | | `filterMinAmount` / `filterMaxAmount` | 金额区间 | `null` | | `filterRemark` | 备注关键字 | `''` | - **读取**:`loadPrefs()`(第 474 行)在 `onMounted`(第 699 行)调用,逐项校验白名单;兼容旧字段 `filterPaymentMethod`(第 486 行)。 - **保存**:`watch` 监听上述变量,任一变化调用 `savePrefs()`。 - **跨页写入**:点击趋势图跳转收支列表时,`applyTrendNavigation()`(第 381 行)把统计口径与筛选**合并写入**收支列表的偏好键,并保留分页、排序等既有设置。 - **目的**:下次进入统计面板自动还原时间范围、粒度与全部过滤条件。 ### 收支列表筛选偏好 `ledgerly.transaction.filterPrefs` - **文件**:`frontend/src/views/TransactionList.vue`(键名第 369 行) - **数据结构**(保存见 `saveFilterPrefs()` 第 396 行): | 字段 | 含义 | 默认值 | |---|---|---| | `filterType` | 收支类型 | `[]` | | `filterCategory` | 分类(`0` 表示未分类) | `[]` | | `filterChannel` | 交易渠道 | `[]` | | `filterDateRange` | 日期范围 `[number, number]` | `null` | | `filterIncludeInStats` | 计入统计三态 `null`/`'true'`/`'false'` | `null` | | `filterRemark` | 备注关键字 | `''` | | `filterMinAmount` / `filterMaxAmount` | 金额区间 | `null` | | `pageSize` | 每页条数 `10`/`20`/`50`/`100`/`200`/`500` | `20` | | `sortValue` | 排序 `transactionTime-desc` 等 4 种 | `transactionTime-desc` | | `filterTag` | 标签(`0` 表示无标签) | `[]` | - **读取**:`loadFilterPrefs()`(第 372 行)在 `onMounted`(第 2030 行)调用;对 `pageSize` 校验白名单、对 `sortValue` 校验选项,兼容旧字段 `filterPaymentMethod`。 - **保存**:`watch` 深度监听 11 个变量,任一变化自动保存(第 417 行)。 - **目的**:记住筛选条件、每页条数与排序方式,下次进入自动还原。 ### AI 相关偏好(收支列表) | 键名 | 取值 | 说明 | |---|---|---| | `ledgerly.transaction.aiScope` | `BOTH`/`CATEGORY`/`TAG` | 批量 AI 建议范围,非法值回退 `BOTH`(第 146 行) | | `ledgerly.transaction.aiRecordTab` | `text`/`image` | AI 记账方式,非法值回退 `text`(第 206 行) | | `ledgerly.transaction.aiQueryHistory` | 字符串数组(≤10) | AI 自然语言查询历史,最新在前 | | `ledgerly.transaction.remarkHistory` | 字符串数组(≤10) | 备注关键词历史,失焦/回车记录 | - **`aiRecordTab`** 是 `AGENTS.md` 点名的参考实现:记住用户上次用的记账方式(文字或图片),下次打开 AI 记账弹窗直接落到该 Tab。 - **关键词历史**统一由 `composables/useKeywordHistory.ts` 管理:去重(忽略大小写)、最新在前、上限 10 条,损坏数据自动忽略,下拉末尾提供「清空历史」项。 - 读取:`load()`;记录:`record()`;清空:`clear()`。 - 三处使用:AI 查询历史、备注历史(均在 `TransactionList.vue`)、统计面板 AI 搜索历史(`Dashboard.vue`,键 `ledgerly.dashboard.aiSearchHistory`)。 ### 账单导入类型 `ledgerly.billImport.billType` - **文件**:`frontend/src/views/BillImportPanel.vue`(第 25 行) - **取值**:账单来源编码(`ALIPAY`、`WECHAT`、`CMB` 等) - **读取**:`readBillTypePref()`(第 62 行)作为初始值;`loadBillTypes()` 拿到后端列表后校正,非法值回退首个来源。 - **保存**:`watch(billType)`(第 226 行)在切换类型时写入,同时清空已选文件,避免格式与类型不匹配。 - **目的**:记住上次选择的账单来源。 ### 账单关系类型 `ledgerly.transaction.relationType` - **文件**:`frontend/src/components/TransactionRelationEditorModal.vue`(第 14 行) - **取值**:`PARENT_CHILD`(默认)、`SIBLING` - **读取**:`loadPreferredRelationType()`(第 81 行)在新建弹窗初始化时调用。 - **保存**:`watch(editorType)`(第 69 行)**仅在创建模式**(`editingGroupId === null`)写入,编辑既有关系组不覆盖偏好。 - **目的**:新建关系组时默认沿用上次的关系类型。 ### 页面标签页 `ledgerly.emailBill.tab` / `ledgerly.logs.tab` - **文件**:`views/EmailBillManage.vue`、`views/Logs.vue` - **取值**:邮箱页 `configs`/`senderRules`/`messages`;日志页 `http`/`login`/`app` - **读取**:路由 `/email-bills`、`/logs` 的 `redirect` 读取偏好并拼接子路径(`router/index.ts`),带白名单校验,无记录回退默认。 - **保存**:`watch(activeTab, ..., { immediate: true })` 在 Tab 切换时写入。 - **目的**:下次点击「邮箱账单」「日志」回到上次标签页。 - **说明**:这两个是 `AGENTS.md` 点名的参考实现,早期未加前缀,现已统一为 `ledgerly.` 前缀并做旧键迁移。 ### AI 查询浮窗位置 `ledgerly.transaction.aiQueryWindow` / `ledgerly.dashboard.aiQueryWindow` - **文件**:`components/FloatingPanel.vue`(键经 `storageKey` 传入,由 `AiQueryWindow.vue` 复用) - **取值**:`{ x, y, width, height }`,非法值回退居中与默认尺寸(默认 560 × 360)。 - **保存**:拖拽或缩放结束时写入;读取在浮窗初始化时。 - **目的**:记住 AI 查询浮窗上次的位置与大小,下次打开保持原样。 ### 各表格每页条数 `ledgerly.pagination.tablePageSizes` - **文件**:`frontend/src/utils/tablePageSizePrefs.ts`、`composables/useTablePageSize.ts` - **取值**:`Record`,按表格键(`transaction`、`categoryExpense`、`rule`、`logsHttp`、`smart-query` 等)分别记录。 - **优先级**:本机记录(且须在当前分页候选中)> 全局默认值(后端 `paginationPrefs`)> 20。 - **保存**:切换某表格每页条数时写入;候选列表与全局默认值在[设置 → 外观设置](/guide/settings#外观设置)中配置(存后端)。 ## sessionStorage 偏好:滚动位置 - **文件**:`frontend/src/views/Layout.vue`(前缀第 83 行,键为 `ledgerly.scroll.<路由路径>`) - **保存**:内容区 `scroll` 事件经 `requestAnimationFrame` 节流写入;路由切换前保存离开页;`beforeunload` 兜底(第 172 行)。 - **读取**:`restoreScroll()`(第 139 行)在 `onMounted` 与路由切换后恢复;因页面内容异步加载、切换后高度可能不足,用 `rAF` 轮询等待内容高度足够再定位,2 秒超时后按当前可达最大值定位。 - **目的**:刷新或页面切换后恢复阅读位置;仅当前会话有效,关闭浏览器即失效。 ## 后端按用户同步的偏好 以下偏好需要跟随账号(换设备/多端一致),因此存后端,按当前用户隔离,前端通过设置页读写。 ### 「不计入统计」展示样式 - **接口**:`GET /api/setting/excluded-display-style`、`PUT /api/setting/excluded-display-style`(`api/setting.ts:14`) - **取值**:`strikethrough`(默认,整行删除线)、`muted`(灰色标签) - **前端**:设置页 `components/DisplayConfigPanel.vue` 编辑保存;收支列表 `TransactionList.vue:647` 在 `onMounted` 读取并应用到「不计入统计」记录(失败保持默认删除线)。 - **目的**:统一用户对「不计入统计」记录的外观选择。 ### 账单来源「计入统计」默认值 - **接口**:`GET/PUT /api/setting/bill-source-include-in-stats`(`api/setting.ts:4`) - **数据结构**:`{ [账单来源编码]: boolean }` - **前端**:设置页 `components/BillSourceStatsPanel.vue` 编辑保存(可点整行或开关切换)。 - **目的**:按账单来源设置导入时默认是否计入统计,仅影响统计口径,不改变导入结果。 ### 显示已隐藏备注的记录 - **接口**:`GET/PUT /api/setting/show-hidden-data`(`api/setting.ts`) - **数据结构**:`{ show: boolean }`,默认 `false`。 - **存储**:`t_user_preference` 的 `pref_key = showHiddenData`,值以 `"true"`/`"false"` 保存。 - **前端**:设置页 `components/DisplayConfigPanel.vue` 切换即存;收支列表与 Excel 导出据此过滤(关闭时排除 `remark_hidden = true` 的记录),统计备注搜索不受影响。 - **目的**:统一控制「已隐藏备注的记录」是否出现在列表与导出中。 ### 表格分页偏好 - **接口**:`GET/PUT /api/setting/pagination-prefs`(`api/setting.ts`) - **数据结构**:`{ options: number[], defaultSize: number }`,默认候选 `[10, 20, 50, 100]`、默认 20;后端会规范化(去重升序、1–1000、最多 10 项)。 - **存储**:`t_user_preference` 的 `pref_key = paginationPrefs`(JSON)。 - **前端**:设置页 `components/DisplayConfigPanel.vue` 编辑、防抖 500ms 自动保存;`composables/usePageSizeOptions.ts` 提供全局单例,`useTablePageSize(tableKey)` 读取各表记忆值。 - **目的**:统一所有表格的分页候选与默认每页条数。 ### 导航顺序与显隐 - **接口**:`GET/PUT /api/setting/nav-menu`(`api/setting.ts`) - **数据结构**:`{ items: [{ key, visible }] }`,未配置时以前端 `utils/navItems.ts` 的默认顺序与可见性为准。 - **存储**:`t_user_preference` 的 `pref_key = navMenu`(JSON)。 - **前端**:设置页 `components/NavMenuConfigPanel.vue`(`vuedraggable` 拖拽 + 开关,600ms 防抖保存、可恢复默认);`composables/useNavMenu.ts` 与后端合并,`Layout.vue` 据此渲染。隐藏仅移除菜单入口,路由仍可访问。 - **目的**:按账号自定义左侧导航的顺序与显隐,跨设备生效。 ### AI 配置 - **接口**:`GET /api/ai/config`、`PUT /api/ai/config`(`api/ai.ts`) - **数据结构**:`{ enabled, apiKey, hasApiKey, baseUrl, model, visionModel }`,`apiKey` 留空表示不修改原值。 - **前端**:设置页 `components/AiConfigPanel.vue` 编辑保存。 - **目的**:用户级 AI 能力配置;其他页面的 AI 元素统一经 `getAiConfig().enabled` 控制显隐。 > 严格说 AI 配置属「功能配置」而非纯界面偏好,但因按用户存储,一并列出。设置页中的数据库配置、注册开关、定时通知为**系统级全局配置**,不在此列。 ## 未实现的偏好 以下常见界面偏好当前**没有**持久化实现,改动时无需假设其存在: - **主题 / 暗色模式**:无主题存储键,界面主题固定。 - **侧边栏整体折叠 / 展开**:`Layout.vue` 使用固定 `NLayoutSider`,无折叠状态(但菜单项的**顺序与显隐**可配置,见上「导航顺序与显隐」)。 - **表格列显示 / 隐藏 / 顺序**:各页列配置为固定 `computed`。 - **货币 / 金额格式**:无货币偏好。 - **独立日期格式**:仅由语言 `locale` 间接驱动 Naive UI 日期本地化,无单独偏好。 - **IndexedDB / cookie**:未使用。 - **Pinia 持久化插件 / vueuse `useStorage`**:未安装、未使用。 ## 附:登录凭证 `token` - **文件**:`frontend/src/store/user.ts`(第 8 行) - **保存**:`setToken()` 写入 `localStorage`;`logout()` 移除。 - **使用**:`api/request.ts` 请求拦截器读取并写入 `Authorization` 头。 - **说明**:这是认证状态,**不属于界面偏好**,列出以免混淆。`nickname`、`userId` 不持久化,每次由 `fetchUserInfo()` 从后端获取。 ## 新增偏好的约定 新增一项用户界面偏好时: 1. 存储键常量集中登记在 `frontend/src/utils/storageKeys.ts` 的 `STORAGE_KEYS`,以 `ledgerly.` 前缀命名、按页面/模块分段(如 `ledgerly.transaction.xxx`);涉及改名时在 `LEGACY_STORAGE_KEYS` 登记旧键并用 `migrateKey` 迁移。 2. 读写统一经 `frontend/src/utils/storage.ts`,**禁止**在业务代码中直接调用 `localStorage`/`sessionStorage`。 3. 优先用 `watch` 自动保存,避免要求用户手动点保存。 4. 读取时对取值做白名单校验,非法或损坏数据回退默认项;写入包 `try/catch`,忽略存储异常。 5. 组件内的字符串型偏好用 `useStringPreference`,关键词历史用 `useKeywordHistory`,浮窗位置尺寸用 `FloatingPanel`(键经 `storageKey` 传入)。 6. 需要跨设备一致时,改存后端并按当前用户隔离,而非 `localStorage`。