refactor: 前端本地偏好统一为公共存储层

- 新增 frontend/src/utils/storageKeys.ts 集中维护存储键常量并登记旧键迁移
- 新增 frontend/src/utils/storage.ts 统一 localStorage/sessionStorage 读写与异常容错
- 新增 frontend/src/composables/usePreference.ts 提供 useStringPreference 响应式字符串偏好
- 新增 frontend/src/utils/transactionFilterPrefs.ts 抽出收支筛选偏好字段定义与共享读写
- 存储键统一以 finance. 前缀命名,locale、emailBill.tab、logs.tab 迁移到新键并自动迁移旧值
- 各页面与组件改用公共层,移除重复的 try/catch 与跨组件重复的序列化逻辑
- 认证凭据 token 属登录状态,保持原键名不纳入 finance. 前缀
This commit is contained in:
2026-09-23 09:41:55 +08:00
parent 66d6ab86b5
commit eb358d0ba1
16 changed files with 396 additions and 242 deletions
+44 -84
View File
@@ -16,7 +16,10 @@ import { getTagList, createTag } from '../api/tag'
import { getChannelLabel } from '../utils/channel'
import { renderCategoryLabel } from '../utils/render'
import { useKeywordHistory } from '../composables/useKeywordHistory'
import { useStringPreference } from '../composables/usePreference'
import { openSse } from '../utils/sse'
import { STORAGE_KEYS } from '../utils/storageKeys'
import { loadTransactionFilterPrefs, saveTransactionFilterPrefs } from '../utils/transactionFilterPrefs'
import BillImportModal from './BillImportModal.vue'
import TransactionDetailModal from '../components/TransactionDetailModal.vue'
import TransactionRelationEditorModal from '../components/TransactionRelationEditorModal.vue'
@@ -146,34 +149,18 @@ const aiEnabled = ref(false) // AI 建议是否启用
const aiLoading = ref(false) // AI 建议生成状态
const aiFormLoading = ref(false) // 编辑弹窗内 AI 推荐状态
/** AI 建议范围偏好存储键 */
const AI_SCOPE_KEY = 'finance.transaction.aiScope'
const AI_SCOPE_KEY = STORAGE_KEYS.transactionAiScope
/** 读取上次的 AI 建议范围偏好,非法值回退 BOTH */
function loadAiScope(): string {
try {
const saved = localStorage.getItem(AI_SCOPE_KEY)
return saved && ['BOTH', 'CATEGORY', 'TAG'].includes(saved) ? saved : 'BOTH'
} catch (e) {
return 'BOTH'
}
}
const aiScope = ref(loadAiScope()) // 批量 AI 建议范围:BOTH/CATEGORY/TAG(记忆用户偏好)
// 批量 AI 建议范围:BOTH/CATEGORY/TAG(记忆用户偏好,非法值回退 BOTH)
const aiScope = useStringPreference(AI_SCOPE_KEY, 'BOTH', {
validate: (value) => ['BOTH', 'CATEGORY', 'TAG'].includes(value),
})
// 批量 AI 建议范围选项
const aiScopeOptions = computed(() => [
{ label: t('ai.scope.both'), value: 'BOTH' },
{ label: t('ai.scope.category'), value: 'CATEGORY' },
{ label: t('ai.scope.tag'), value: 'TAG' },
])
// 记住用户选择的 AI 建议范围
watch(aiScope, (value) => {
try {
localStorage.setItem(AI_SCOPE_KEY, value)
} catch (e) {
// 忽略存储异常
}
})
const aiNewLoading = ref(false) // 编辑弹窗内 AI 生成新分类状态
const aiNewSaving = ref(false) // 新分类保存状态
// AI 生成但尚未保存的新分类,需用户确认后再落库
@@ -197,28 +184,16 @@ const showAiRecordModal = ref(false) // AI 记账弹窗显示状态
const aiRecordText = ref('') // AI 记账输入
const aiRecording = ref(false) // AI 记账解析状态
/** AI 记账方式偏好存储键(参考邮箱页 Tab 记忆) */
const AI_RECORD_TAB_KEY = 'finance.transaction.aiRecordTab'
const AI_RECORD_TAB_KEY = STORAGE_KEYS.transactionAiRecordTab
/** 读取上次的 AI 记账方式偏好,非法值回退文字记账 */
function loadAiRecordTab(): 'text' | 'image' {
try {
const saved = localStorage.getItem(AI_RECORD_TAB_KEY)
return saved === 'image' ? 'image' : 'text'
} catch (e) {
return 'text'
}
}
const aiRecordTab = ref<'text' | 'image'>(loadAiRecordTab()) // AI 记账方式:文字/图片(记忆用户偏好)
// 记住用户选择的 AI 记账方式
watch(aiRecordTab, (value) => {
try {
localStorage.setItem(AI_RECORD_TAB_KEY, value)
} catch (e) {
// 忽略存储异常
}
// AI 记账方式:文字/图片(记忆用户偏好,非法值回退文字记账)
const aiRecordTab = useStringPreference(AI_RECORD_TAB_KEY, 'text', {
validate: (value) => value === 'text' || value === 'image',
})
/** AI 查询历史与浮窗位置尺寸存储键 */
const AI_QUERY_HISTORY_KEY = STORAGE_KEYS.transactionAiQueryHistory
const AI_QUERY_WINDOW_KEY = STORAGE_KEYS.transactionAiQueryWindow
/** 图片记账单次最多上传的图片数量 */
const MAX_AI_RECORD_IMAGES = 5
const aiRecordFileList = ref<UploadFileInfo[]>([]) // 图片记账上传文件列表
@@ -291,7 +266,7 @@ const filterDateRange = ref<[number, number] | null>(null) // 日期范围筛选
const filterIncludeInStats = ref<string | null>(null) // 计入统计筛选:null-全部,'true'-计入,'false'-不计入
const filterRemark = ref('') // 备注关键字搜索
// 备注关键词历史存储键
const REMARK_HISTORY_KEY = 'finance.transaction.remarkHistory'
const REMARK_HISTORY_KEY = STORAGE_KEYS.transactionRemarkHistory
// 备注关键词历史(最近输入,最新在前)
const remarkHistory = useKeywordHistory({
storageKey: REMARK_HISTORY_KEY,
@@ -359,52 +334,37 @@ const includeInStatsOptions = computed(() => [
{ label: t('transaction.includeFilter.no'), value: 'false' },
])
/** 筛选条件偏好存储键 */
const FILTER_PREFS_KEY = 'finance.transaction.filterPrefs'
/** 从 localStorage 读取上次的筛选条件偏好并还原 */
function loadFilterPrefs() {
try {
const raw = localStorage.getItem(FILTER_PREFS_KEY)
if (!raw) return
const p = JSON.parse(raw)
if (Array.isArray(p.filterType)) filterType.value = p.filterType
if (Array.isArray(p.filterCategory)) filterCategory.value = p.filterCategory
// 兼容旧版本以 filterPaymentMethod 存储的偏好
const savedChannel = Array.isArray(p.filterChannel) ? p.filterChannel : p.filterPaymentMethod
if (Array.isArray(savedChannel)) filterChannel.value = savedChannel
if (Array.isArray(p.filterDateRange) && p.filterDateRange.length === 2) filterDateRange.value = p.filterDateRange
if (p.filterIncludeInStats === 'true' || p.filterIncludeInStats === 'false') filterIncludeInStats.value = p.filterIncludeInStats
if (typeof p.filterRemark === 'string') filterRemark.value = p.filterRemark
if (typeof p.filterMinAmount === 'number') filterMinAmount.value = p.filterMinAmount
if (typeof p.filterMaxAmount === 'number') filterMaxAmount.value = p.filterMaxAmount
if (typeof p.pageSize === 'number' && pageSizes.includes(p.pageSize)) pageSize.value = p.pageSize
if (typeof p.sortValue === 'string' && sortOptions.value.some((o) => o.value === p.sortValue)) sortValue.value = p.sortValue
if (Array.isArray(p.filterTag)) filterTag.value = p.filterTag
} catch (e) {
// 忽略损坏的偏好数据
}
const p = loadTransactionFilterPrefs()
if (p.filterType) filterType.value = p.filterType
if (p.filterCategory) filterCategory.value = p.filterCategory
if (p.filterChannel) filterChannel.value = p.filterChannel
if (p.filterDateRange) filterDateRange.value = p.filterDateRange
if (p.filterIncludeInStats) filterIncludeInStats.value = p.filterIncludeInStats
if (typeof p.filterRemark === 'string') filterRemark.value = p.filterRemark
if (typeof p.filterMinAmount === 'number') filterMinAmount.value = p.filterMinAmount
if (typeof p.filterMaxAmount === 'number') filterMaxAmount.value = p.filterMaxAmount
if (typeof p.pageSize === 'number' && pageSizes.includes(p.pageSize)) pageSize.value = p.pageSize
if (typeof p.sortValue === 'string' && sortOptions.value.some((o) => o.value === p.sortValue)) sortValue.value = p.sortValue
if (p.filterTag) filterTag.value = p.filterTag
}
/** 将当前筛选条件写入 localStorage,下次进入自动还原 */
function saveFilterPrefs() {
try {
localStorage.setItem(FILTER_PREFS_KEY, JSON.stringify({
filterType: filterType.value,
filterCategory: filterCategory.value,
filterChannel: filterChannel.value,
filterDateRange: filterDateRange.value,
filterIncludeInStats: filterIncludeInStats.value,
filterRemark: filterRemark.value,
filterMinAmount: filterMinAmount.value,
filterMaxAmount: filterMaxAmount.value,
pageSize: pageSize.value,
sortValue: sortValue.value,
filterTag: filterTag.value,
}))
} catch (e) {
// 忽略写入失败
}
saveTransactionFilterPrefs({
filterType: filterType.value,
filterCategory: filterCategory.value,
filterChannel: filterChannel.value,
filterDateRange: filterDateRange.value,
filterIncludeInStats: filterIncludeInStats.value,
filterRemark: filterRemark.value,
filterMinAmount: filterMinAmount.value,
filterMaxAmount: filterMaxAmount.value,
pageSize: pageSize.value,
sortValue: sortValue.value,
filterTag: filterTag.value,
})
}
// 筛选条件变化时自动保存用户偏好
@@ -2532,8 +2492,8 @@ onBeforeUnmount(() => {
:placeholder="t('ai.query.placeholder')"
:examples="aiQueryExamples"
:loading="aiQuerying"
history-key="finance.transaction.aiQueryHistory"
storage-key="finance.transaction.aiQueryWindow"
:history-key="AI_QUERY_HISTORY_KEY"
:storage-key="AI_QUERY_WINDOW_KEY"
@submit="handleAiQuery"
/>
</div>