Files
ledgerly-docs/dev/preferences.md
T
rosercode ca1b8c05d5 docs: 补充统计面板图表点击跳转开关说明
统计面板图表的点击下钻交互新增了可关闭开关(默认开启),可在统计面板筛选区或设置页「外观设置」关闭;关闭后点击趋势柱/饼图不再跳转,并隐藏点击提示与饼图手型光标。本次同步更新使用与开发文档,并重截外观设置面板配图。

变更内容:
- `guide/dashboard.md`:筛选条件小节与「点击图表查看明细」小节补充开关说明(两处入口、关闭效果与实时同步)。
- `guide/settings.md`:外观设置由三组改为四组,新增「图表点击查看明细」并对其余分组重新编号;配图 `elements/panel-display.png` 重截并上传 OSS。
- `dev/preferences.md`:后端按用户同步偏好总览表加行,并新增「图表点击跳转明细」小节(接口、默认值、存储键、共享 composable 与消费入口)。
- `dev/api.md`:新增 `GET/PUT /api/setting/chart-click-navigation` 接口。
- `dev/data-model.md`:`t_user_preference` 已用偏好键补充 `chartClickNavigation`。
2026-10-13 16:02:49 +08:00

292 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 前端用户偏好与持久化
本文梳理前端「用户偏好」的保存逻辑:**为什么保存、保存了哪些偏好、存在哪里、何时读写、目的是什么**。
## 为什么保存
汇账的不少界面是「多 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/chart-click-navigation` | 统计面板图表点击是否跳转明细 | `components/DisplayConfigPanel.vue` / `views/Dashboard.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<tableKey, number>`,按表格键(`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/chart-click-navigation`(`api/setting.ts`)
- **数据结构**:`{ enabled: boolean }`,默认 `true`(开启)。
- **存储**:`t_user_preference` 的 `pref_key = chartClickNavigation`,值以 `"true"`/`"false"` 保存;未配置时读取默认开启。
- **前端**:共享单例 `composables/useChartClickNavigation.ts`(`chartClickEnabled` + `ensureChartClickEnabledLoaded` / `refreshChartClickEnabled`,读取失败回退默认开启);设置页 `components/DisplayConfigPanel.vue` 与统计面板 `views/Dashboard.vue` 筛选区两处开关读写同一状态、即时联动。关闭后 `Dashboard.vue` 的点击跳转入口(`triggerChartNavigation` / `handleTrendBlankClick`)直接返回,并重绘图表以同步光标、隐藏点击提示。
- **目的**:允许用户关闭统计面板图表的点击下钻交互,跨设备生效。
### 表格分页偏好
- **接口**:`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`。