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
+3 -11
View File
@@ -3,6 +3,7 @@
import { ref, watch, onBeforeUnmount, nextTick } from 'vue' import { ref, watch, onBeforeUnmount, nextTick } from 'vue'
import { NButton, NIcon } from 'naive-ui' import { NButton, NIcon } from 'naive-ui'
import { CloseOutline } from '@vicons/ionicons5' import { CloseOutline } from '@vicons/ionicons5'
import { readJson, writeJson } from '../utils/storage'
/** 浮窗位置与尺寸 */ /** 浮窗位置与尺寸 */
interface PanelRect { interface PanelRect {
@@ -67,9 +68,7 @@ function clampRect() {
/** 读取持久化的位置与尺寸,非法值回退默认并居中 */ /** 读取持久化的位置与尺寸,非法值回退默认并居中 */
function loadRect() { function loadRect() {
if (props.storageKey) { if (props.storageKey) {
try { const parsed = readJson<Partial<PanelRect> | null>(props.storageKey, null)
const raw = localStorage.getItem(props.storageKey)
const parsed = raw ? JSON.parse(raw) : null
const x = Number(parsed?.x) const x = Number(parsed?.x)
const y = Number(parsed?.y) const y = Number(parsed?.y)
const width = Number(parsed?.width) const width = Number(parsed?.width)
@@ -84,9 +83,6 @@ function loadRect() {
clampRect() clampRect()
return return
} }
} catch {
// 忽略读取失败
}
} }
rect.value = defaultRect() rect.value = defaultRect()
} }
@@ -94,11 +90,7 @@ function loadRect() {
/** 持久化位置与尺寸,忽略存储异常 */ /** 持久化位置与尺寸,忽略存储异常 */
function saveRect() { function saveRect() {
if (!props.storageKey) return if (!props.storageKey) return
try { writeJson(props.storageKey, rect.value)
localStorage.setItem(props.storageKey, JSON.stringify(rect.value))
} catch {
// 忽略写入失败
}
} }
// 拖拽状态 // 拖拽状态
@@ -9,9 +9,11 @@ import type { TransactionGroup, TransactionRelationType, TransactionGroupPayload
import { getTransactionPage } from '../api/transaction' import { getTransactionPage } from '../api/transaction'
import { getAiConfig, parseRelationByAi } from '../api/ai' import { getAiConfig, parseRelationByAi } from '../api/ai'
import { getChannelLabel } from '../utils/channel' import { getChannelLabel } from '../utils/channel'
import { readString, writeString } from '../utils/storage'
import { STORAGE_KEYS } from '../utils/storageKeys'
/** 关系类型偏好存储键(界面偏好统一以 finance. 前缀命名) */ /** 关系类型偏好存储键(界面偏好统一以 finance. 前缀命名) */
const RELATION_TYPE_KEY = 'finance.transaction.relationType' const RELATION_TYPE_KEY = STORAGE_KEYS.transactionRelationType
const props = defineProps<{ const props = defineProps<{
/** 弹窗显示状态 */ /** 弹窗显示状态 */
@@ -79,21 +81,13 @@ onMounted(loadAiConfig)
/** 读取上次使用的关系类型偏好,非法值或异常回退 PARENT_CHILD */ /** 读取上次使用的关系类型偏好,非法值或异常回退 PARENT_CHILD */
function loadPreferredRelationType(): TransactionRelationType { function loadPreferredRelationType(): TransactionRelationType {
try { const saved = readString(RELATION_TYPE_KEY)
const saved = localStorage.getItem(RELATION_TYPE_KEY)
return saved === 'PARENT_CHILD' || saved === 'SIBLING' ? saved : 'PARENT_CHILD' return saved === 'PARENT_CHILD' || saved === 'SIBLING' ? saved : 'PARENT_CHILD'
} catch (e) {
return 'PARENT_CHILD'
}
} }
/** 保存关系类型偏好,忽略存储异常 */ /** 保存关系类型偏好,忽略存储异常 */
function savePreferredRelationType(type: TransactionRelationType) { function savePreferredRelationType(type: TransactionRelationType) {
try { writeString(RELATION_TYPE_KEY, type)
localStorage.setItem(RELATION_TYPE_KEY, type)
} catch (e) {
// 忽略存储异常
}
} }
/** 加载关系名称历史(按当前关系类型过滤) */ /** 加载关系名称历史(按当前关系类型过滤) */
+4 -16
View File
@@ -4,6 +4,7 @@ import type { Ref } from 'vue'
import type { AutoCompleteOption } from 'naive-ui' import type { AutoCompleteOption } from 'naive-ui'
import { NIcon } from 'naive-ui' import { NIcon } from 'naive-ui'
import { CloseCircleOutline } from '@vicons/ionicons5' import { CloseCircleOutline } from '@vicons/ionicons5'
import { readJson, removeKey, writeJson } from '../utils/storage'
/** 关键词历史配置 */ /** 关键词历史配置 */
export interface KeywordHistoryConfig { export interface KeywordHistoryConfig {
@@ -30,15 +31,10 @@ export function useKeywordHistory(config: KeywordHistoryConfig) {
/** 从 localStorage 读取历史,忽略损坏数据 */ /** 从 localStorage 读取历史,忽略损坏数据 */
function load() { function load() {
try { const parsed = readJson<unknown>(storageKey, [])
const raw = localStorage.getItem(storageKey)
const parsed = raw ? JSON.parse(raw) : []
history.value = Array.isArray(parsed) history.value = Array.isArray(parsed)
? parsed.filter((item) => typeof item === 'string' && item.trim()).slice(0, max) ? parsed.filter((item) => typeof item === 'string' && item.trim()).slice(0, max)
: [] : []
} catch {
history.value = []
}
} }
/** 记录当前关键词:去重(忽略大小写)、最新在前、上限截断 */ /** 记录当前关键词:去重(忽略大小写)、最新在前、上限截断 */
@@ -47,21 +43,13 @@ export function useKeywordHistory(config: KeywordHistoryConfig) {
if (!value) return if (!value) return
const rest = history.value.filter((item) => item.toLowerCase() !== value.toLowerCase()) const rest = history.value.filter((item) => item.toLowerCase() !== value.toLowerCase())
history.value = [value, ...rest].slice(0, max) history.value = [value, ...rest].slice(0, max)
try { writeJson(storageKey, history.value)
localStorage.setItem(storageKey, JSON.stringify(history.value))
} catch {
// 忽略写入失败
}
} }
/** 清空历史 */ /** 清空历史 */
function clear() { function clear() {
history.value = [] history.value = []
try { removeKey(storageKey)
localStorage.removeItem(storageKey)
} catch {
// 忽略删除失败
}
} }
// 下拉选项:按当前输入过滤,历史非空时末尾追加「清空历史」 // 下拉选项:按当前输入过滤,历史非空时末尾追加「清空历史」
+36
View File
@@ -0,0 +1,36 @@
// 偏好 composable:将字符串型界面偏好封装为响应式 ref,自动读取、校验回退与持久化
import { ref, watch } from 'vue'
import type { Ref } from 'vue'
import { migrateKey, readString, writeString } from '../utils/storage'
/** 字符串偏好配置 */
export interface StringPreferenceOptions {
/** 历史存储键,首次读取时迁移到当前键 */
legacyKeys?: string[]
/** 合法值校验,返回 false 时回退默认值 */
validate?: (value: string) => boolean
}
/**
* 管理字符串型界面偏好:初始化时迁移旧键并读取,值变化时自动写入。
*
* :param key: 当前存储键
* :param defaultValue: 默认值(键缺失或值非法时使用)
* :param options: 旧键迁移与合法值校验
* :returns: 与存储同步的字符串 ref
*/
export function useStringPreference(
key: string,
defaultValue: string,
options: StringPreferenceOptions = {},
): Ref<string> {
const { legacyKeys = [], validate } = options
// 首次读取前迁移历史键,保证老用户偏好不丢失
legacyKeys.forEach((legacy) => migrateKey(legacy, key))
const initial = readString(key, defaultValue)
const value = ref<string>(validate && !validate(initial) ? defaultValue : initial)
watch(value, (next) => writeString(key, next))
return value
}
+7 -3
View File
@@ -1,6 +1,8 @@
// 国际化入口:由对照消息构建各语言包,维护当前语言、持久化与 <html lang> 同步 // 国际化入口:由对照消息构建各语言包,维护当前语言、持久化与 <html lang> 同步
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
import messages from './messages' import messages from './messages'
import { migrateKey, readString, writeString } from '../utils/storage'
import { LEGACY_STORAGE_KEYS, STORAGE_KEYS } from '../utils/storageKeys'
/** 支持的语言标识 */ /** 支持的语言标识 */
export type LocaleKey = 'zh-CN' | 'en-US' | 'ja-JP' export type LocaleKey = 'zh-CN' | 'en-US' | 'ja-JP'
@@ -13,7 +15,7 @@ interface MessageLeaf {
} }
// 语言偏好存储键 // 语言偏好存储键
const STORAGE_KEY = 'locale' const STORAGE_KEY = STORAGE_KEYS.locale
// 支持的语言列表 // 支持的语言列表
const SUPPORTED: LocaleKey[] = ['zh-CN', 'en-US', 'ja-JP'] const SUPPORTED: LocaleKey[] = ['zh-CN', 'en-US', 'ja-JP']
@@ -40,7 +42,9 @@ function buildLocaleMessages(node: unknown, lang: 'zh' | 'en' | 'ja'): any {
/** 解析初始语言:localStorage > 浏览器语言 > 简体中文 */ /** 解析初始语言:localStorage > 浏览器语言 > 简体中文 */
function resolveInitialLocale(): LocaleKey { function resolveInitialLocale(): LocaleKey {
const saved = localStorage.getItem(STORAGE_KEY) // 读取前迁移旧键 locale,保证老用户语言偏好不丢失
migrateKey(LEGACY_STORAGE_KEYS.locale, STORAGE_KEY)
const saved = readString(STORAGE_KEY)
if (saved && SUPPORTED.includes(saved as LocaleKey)) { if (saved && SUPPORTED.includes(saved as LocaleKey)) {
return saved as LocaleKey return saved as LocaleKey
} }
@@ -74,7 +78,7 @@ export function getLocale(): LocaleKey {
/** 切换语言:更新 i18n、持久化到 localStorage,并同步 <html lang> */ /** 切换语言:更新 i18n、持久化到 localStorage,并同步 <html lang> */
export function setLocale(locale: LocaleKey) { export function setLocale(locale: LocaleKey) {
i18n.global.locale.value = locale i18n.global.locale.value = locale
localStorage.setItem(STORAGE_KEY, locale) writeString(STORAGE_KEY, locale)
document.documentElement.lang = locale document.documentElement.lang = locale
} }
+8 -2
View File
@@ -3,6 +3,8 @@ import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '../store/user' import { useUserStore } from '../store/user'
import { useDatabaseStore } from '../store/database' import { useDatabaseStore } from '../store/database'
import { useRegistrationStore } from '../store/registration' import { useRegistrationStore } from '../store/registration'
import { migrateKey, readString } from '../utils/storage'
import { LEGACY_STORAGE_KEYS, STORAGE_KEYS } from '../utils/storageKeys'
// 创建路由实例,使用 HTML5 History 模式 // 创建路由实例,使用 HTML5 History 模式
const router = createRouter({ const router = createRouter({
@@ -68,7 +70,9 @@ const router = createRouter({
// 跳转到上次访问的标签页,无记录时默认邮箱配置 // 跳转到上次访问的标签页,无记录时默认邮箱配置
redirect: () => { redirect: () => {
const tabs = ['configs', 'senderRules', 'messages'] const tabs = ['configs', 'senderRules', 'messages']
const saved = typeof localStorage !== 'undefined' ? localStorage.getItem('emailBill.tab') : null // 读取前迁移旧键,保证老用户标签页偏好不丢失
migrateKey(LEGACY_STORAGE_KEYS.emailBillTab, STORAGE_KEYS.emailBillTab)
const saved = readString(STORAGE_KEYS.emailBillTab) || null
return `/email-bills/${saved && tabs.includes(saved) ? saved : 'configs'}` return `/email-bills/${saved && tabs.includes(saved) ? saved : 'configs'}`
}, },
}, },
@@ -92,7 +96,9 @@ const router = createRouter({
// 跳转到上次访问的日志标签页,无记录时默认访问日志 // 跳转到上次访问的日志标签页,无记录时默认访问日志
redirect: () => { redirect: () => {
const tabs = ['http', 'login', 'app'] const tabs = ['http', 'login', 'app']
const saved = typeof localStorage !== 'undefined' ? localStorage.getItem('logs.tab') : null // 读取前迁移旧键,保证老用户标签页偏好不丢失
migrateKey(LEGACY_STORAGE_KEYS.logsTab, STORAGE_KEYS.logsTab)
const saved = readString(STORAGE_KEYS.logsTab) || null
return `/logs/${saved && tabs.includes(saved) ? saved : 'http'}` return `/logs/${saved && tabs.includes(saved) ? saved : 'http'}`
}, },
}, },
+7 -3
View File
@@ -2,17 +2,21 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { getUserInfo } from '../api/user' import { getUserInfo } from '../api/user'
import { readString, removeKey, writeString } from '../utils/storage'
/** 登录令牌存储键(认证凭据,不属于界面偏好,不纳入 finance. 前缀) */
const TOKEN_KEY = 'token'
export const useUserStore = defineStore('user', () => { export const useUserStore = defineStore('user', () => {
// 从 localStorage 恢复 token,若不存在则为空字符串 // 从 localStorage 恢复 token,若不存在则为空字符串
const token = ref(localStorage.getItem('token') || '') const token = ref(readString(TOKEN_KEY))
const nickname = ref('') // 用户昵称 const nickname = ref('') // 用户昵称
const userId = ref<number | null>(null) // 用户 ID const userId = ref<number | null>(null) // 用户 ID
/** 设置 token 并持久化到 localStorage */ /** 设置 token 并持久化到 localStorage */
function setToken(t: string) { function setToken(t: string) {
token.value = t token.value = t
localStorage.setItem('token', t) writeString(TOKEN_KEY, t)
} }
/** 设置用户基本信息(昵称和 ID) */ /** 设置用户基本信息(昵称和 ID) */
@@ -36,7 +40,7 @@ export const useUserStore = defineStore('user', () => {
token.value = '' token.value = ''
nickname.value = '' nickname.value = ''
userId.value = null userId.value = null
localStorage.removeItem('token') removeKey(TOKEN_KEY)
} }
return { token, nickname, userId, setToken, setUserInfo, fetchUserInfo, logout } return { token, nickname, userId, setToken, setUserInfo, fetchUserInfo, logout }
+83
View File
@@ -0,0 +1,83 @@
// 本地存储工具:统一 localStorage / sessionStorage 的读写与容错,避免各处重复 try/catch
/** 读取字符串,键不存在或读取异常时回退 fallback */
export function readString(key: string, fallback = ''): string {
try {
return localStorage.getItem(key) ?? fallback
} catch (e) {
return fallback
}
}
/** 读取 JSON,解析失败或读取异常时回退 fallback */
export function readJson<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key)
return raw ? (JSON.parse(raw) as T) : fallback
} catch (e) {
return fallback
}
}
/** 写入字符串,忽略存储异常(如隐私模式) */
export function writeString(key: string, value: string): void {
try {
localStorage.setItem(key, value)
} catch (e) {
// 忽略写入失败
}
}
/** 写入 JSON,忽略存储异常 */
export function writeJson(key: string, value: unknown): void {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch (e) {
// 忽略写入失败
}
}
/** 删除键,忽略存储异常 */
export function removeKey(key: string): void {
try {
localStorage.removeItem(key)
} catch (e) {
// 忽略删除失败
}
}
/** 读取 sessionStorage 字符串,读取异常时回退 fallback */
export function readSessionString(key: string, fallback = ''): string {
try {
return sessionStorage.getItem(key) ?? fallback
} catch (e) {
return fallback
}
}
/** 写入 sessionStorage 字符串,忽略存储异常 */
export function writeSessionString(key: string, value: string): void {
try {
sessionStorage.setItem(key, value)
} catch (e) {
// 忽略写入失败
}
}
/**
* 迁移历史存储键:新键不存在且旧键存在时,将旧值搬运到新键并删除旧键。
*
* :param oldKey: 历史键
* :param newKey: 当前键
*/
export function migrateKey(oldKey: string, newKey: string): void {
try {
if (localStorage.getItem(newKey) !== null) return
const legacy = localStorage.getItem(oldKey)
if (legacy === null) return
localStorage.setItem(newKey, legacy)
localStorage.removeItem(oldKey)
} catch (e) {
// 忽略迁移失败,保持旧键可用
}
}
+47
View File
@@ -0,0 +1,47 @@
// 本地存储键常量:界面/用户偏好统一以 finance. 前缀命名,作为唯一来源供路由与各页面复用
/** 当前使用的本地存储键 */
export const STORAGE_KEYS = {
// 语言偏好
locale: 'finance.locale',
// 邮箱账单页标签页
emailBillTab: 'finance.emailBill.tab',
// 系统日志页标签页
logsTab: 'finance.logs.tab',
// 账单导入的账单类型
billImportBillType: 'finance.billImport.billType',
// 统计面板偏好
dashboardPrefs: 'finance.dashboard.prefs',
// 统计面板 AI 查询历史
dashboardAiSearchHistory: 'finance.dashboard.aiSearchHistory',
// 统计面板 AI 查询浮窗位置尺寸
dashboardAiQueryWindow: 'finance.dashboard.aiQueryWindow',
// 收支记录 AI 建议范围
transactionAiScope: 'finance.transaction.aiScope',
// 收支记录 AI 记账方式
transactionAiRecordTab: 'finance.transaction.aiRecordTab',
// 收支记录备注关键词历史
transactionRemarkHistory: 'finance.transaction.remarkHistory',
// 收支记录筛选偏好(收支记录页与统计面板共用)
transactionFilterPrefs: 'finance.transaction.filterPrefs',
// 账单关系类型
transactionRelationType: 'finance.transaction.relationType',
// 收支记录 AI 查询历史
transactionAiQueryHistory: 'finance.transaction.aiQueryHistory',
// 收支记录 AI 查询浮窗位置尺寸
transactionAiQueryWindow: 'finance.transaction.aiQueryWindow',
// 内容区滚动位置(sessionStorage,按路由拼接后缀)
scrollPrefix: 'finance.scroll.',
} as const
/** 历史遗留的存储键(未带 finance. 前缀),首次读取时迁移到新键 */
export const LEGACY_STORAGE_KEYS = {
locale: 'locale',
emailBillTab: 'emailBill.tab',
logsTab: 'logs.tab',
} as const
/** 生成指定路由的滚动位置存储键 */
export function scrollKey(path: string): string {
return `${STORAGE_KEYS.scrollPrefix}${path}`
}
@@ -0,0 +1,63 @@
// 收支记录筛选偏好:收支记录页与统计面板共用的读写与合并逻辑,字段定义与校验集中于此
import { readJson, writeJson } from './storage'
import { STORAGE_KEYS } from './storageKeys'
/** 收支记录筛选偏好字段 */
export interface TransactionFilterPrefs {
/** 类型筛选(多选) */
filterType: string[]
/** 分类筛选(多选) */
filterCategory: number[]
/** 交易渠道筛选(多选) */
filterChannel: string[]
/** 日期范围筛选(起止时间戳) */
filterDateRange: [number, number] | null
/** 计入统计筛选:null-全部,'true'-计入,'false'-不计入 */
filterIncludeInStats: string | null
/** 备注关键字搜索 */
filterRemark: string
/** 最小金额筛选 */
filterMinAmount: number | null
/** 最大金额筛选 */
filterMaxAmount: number | null
/** 每页条数 */
pageSize: number
/** 排序字段 */
sortValue: string
/** 标签筛选(多选) */
filterTag: number[]
}
/** 读取筛选偏好,仅返回类型合法的字段(兼容旧字段 filterPaymentMethod) */
export function loadTransactionFilterPrefs(): Partial<TransactionFilterPrefs> {
const raw = readJson<Record<string, any>>(STORAGE_KEYS.transactionFilterPrefs, {})
const result: Partial<TransactionFilterPrefs> = {}
if (Array.isArray(raw.filterType)) result.filterType = raw.filterType
if (Array.isArray(raw.filterCategory)) result.filterCategory = raw.filterCategory
// 兼容旧版本以 filterPaymentMethod 存储的渠道筛选
const channel = Array.isArray(raw.filterChannel) ? raw.filterChannel : raw.filterPaymentMethod
if (Array.isArray(channel)) result.filterChannel = channel
if (Array.isArray(raw.filterDateRange) && raw.filterDateRange.length === 2) {
result.filterDateRange = raw.filterDateRange as [number, number]
}
if (raw.filterIncludeInStats === 'true' || raw.filterIncludeInStats === 'false') {
result.filterIncludeInStats = raw.filterIncludeInStats
}
if (typeof raw.filterRemark === 'string') result.filterRemark = raw.filterRemark
if (typeof raw.filterMinAmount === 'number') result.filterMinAmount = raw.filterMinAmount
if (typeof raw.filterMaxAmount === 'number') result.filterMaxAmount = raw.filterMaxAmount
if (typeof raw.pageSize === 'number') result.pageSize = raw.pageSize
if (typeof raw.sortValue === 'string') result.sortValue = raw.sortValue
if (Array.isArray(raw.filterTag)) result.filterTag = raw.filterTag
return result
}
/**
* 合并写入筛选偏好:保留未传入的既有字段(如跳转时无需改动的分页、排序)。
*
* :param patch: 需要更新的字段
*/
export function saveTransactionFilterPrefs(patch: Partial<TransactionFilterPrefs>): void {
const merged = { ...loadTransactionFilterPrefs(), ...patch }
writeJson(STORAGE_KEYS.transactionFilterPrefs, merged)
}
+8 -13
View File
@@ -9,9 +9,11 @@ import {
import type { UploadFileInfo } from 'naive-ui' import type { UploadFileInfo } from 'naive-ui'
import { CloudUploadOutline } from '@vicons/ionicons5' import { CloudUploadOutline } from '@vicons/ionicons5'
import { parseBill, confirmImport, getBillTypes } from '../api/transaction' import { parseBill, confirmImport, getBillTypes } from '../api/transaction'
import type { BillType, BillTypeOption, ParsedBillRow, BillImportItem } from '../api/transaction' import type { BillTypeOption, ParsedBillRow, BillImportItem } from '../api/transaction'
import BillPreviewPanel from '../components/BillPreviewPanel.vue' import BillPreviewPanel from '../components/BillPreviewPanel.vue'
import BillImportResult from '../components/BillImportResult.vue' import BillImportResult from '../components/BillImportResult.vue'
import { useStringPreference } from '../composables/usePreference'
import { STORAGE_KEYS } from '../utils/storageKeys'
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'close'): void (e: 'close'): void
@@ -22,7 +24,7 @@ const message = useMessage()
const { t } = useI18n() // 国际化 const { t } = useI18n() // 国际化
// 账单类型偏好存储键 // 账单类型偏好存储键
const BILL_TYPE_PREFS_KEY = 'finance.billImport.billType' const BILL_TYPE_PREFS_KEY = STORAGE_KEYS.billImportBillType
// 后端下发的账单类型(编码 + 允许的扩展名),组件挂载时加载 // 后端下发的账单类型(编码 + 允许的扩展名),组件挂载时加载
const billTypes = ref<BillTypeOption[]>([]) const billTypes = ref<BillTypeOption[]>([])
@@ -58,13 +60,8 @@ const billTypeOptions = computed(() =>
billTypes.value.map((item) => ({ label: billTypeLabel(item.code), value: item.code })), billTypes.value.map((item) => ({ label: billTypeLabel(item.code), value: item.code })),
) )
/** 读取上次选择的账单类型偏好 */
function readBillTypePref(): string {
return localStorage.getItem(BILL_TYPE_PREFS_KEY) || ''
}
// 当前账单类型,初始值取上次选择(个人偏好),加载后按后端列表校正 // 当前账单类型,初始值取上次选择(个人偏好),加载后按后端列表校正
const billType = ref<BillType>(readBillTypePref()) const billType = useStringPreference(BILL_TYPE_PREFS_KEY, '')
// 各账单类型允许上传的文件扩展名(后端扩展名 + zip 压缩包) // 各账单类型允许上传的文件扩展名(后端扩展名 + zip 压缩包)
const allowedExtensions = computed<Record<string, string[]>>(() => { const allowedExtensions = computed<Record<string, string[]>>(() => {
@@ -97,8 +94,7 @@ async function loadBillTypes() {
const res: any = await getBillTypes() const res: any = await getBillTypes()
billTypes.value = res.data || [] billTypes.value = res.data || []
const codes = billTypes.value.map((item) => item.code) const codes = billTypes.value.map((item) => item.code)
const saved = readBillTypePref() billType.value = codes.includes(billType.value) ? billType.value : (codes[0] || '')
billType.value = codes.includes(saved) ? saved : (codes[0] || '')
} catch (e) { } catch (e) {
// 静默处理,账单类型下拉保持为空 // 静默处理,账单类型下拉保持为空
} }
@@ -222,11 +218,10 @@ function handleClose() {
emit('close') emit('close')
} }
// 切换账单类型时清空已选文件、记录偏好,避免文件格式与类型不匹配 // 切换账单类型时清空已选文件,避免文件格式与类型不匹配(偏好由 useStringPreference 自动持久化)
watch(billType, (value) => { watch(billType, () => {
uploadFileList.value = [] uploadFileList.value = []
file.value = null file.value = null
localStorage.setItem(BILL_TYPE_PREFS_KEY, value)
}) })
// 挂载时重置并加载账单类型(页面进入或弹窗打开时均会触发) // 挂载时重置并加载账单类型(页面进入或弹窗打开时均会触发)
+15 -29
View File
@@ -13,6 +13,9 @@ import { getAiConfig, parseStatisticsByAi } from '../api/ai'
import type { AiStatisticsResult } from '../api/ai' import type { AiStatisticsResult } from '../api/ai'
import { getChannelLabel } from '../utils/channel' import { getChannelLabel } from '../utils/channel'
import { renderCategoryLabel } from '../utils/render' import { renderCategoryLabel } from '../utils/render'
import { readJson, writeJson } from '../utils/storage'
import { STORAGE_KEYS } from '../utils/storageKeys'
import { saveTransactionFilterPrefs } from '../utils/transactionFilterPrefs'
import AiQueryWindow from '../components/AiQueryWindow.vue' import AiQueryWindow from '../components/AiQueryWindow.vue'
// 图表组件异步加载:echarts 体积较大,登录后进入面板时先渲染页面骨架, // 图表组件异步加载:echarts 体积较大,登录后进入面板时先渲染页面骨架,
@@ -374,9 +377,6 @@ function bucketRange(key: string, gran: string): [number, number] {
return [startOfDay(y, 0, 1), endOfDay(y, 11, 31)] return [startOfDay(y, 0, 1), endOfDay(y, 11, 31)]
} }
/** 收支记录页筛选偏好存储键(与 TransactionList 保持一致) */
const TRANSACTION_FILTER_PREFS_KEY = 'finance.transaction.filterPrefs'
/** /**
* 将趋势时间桶与收支类型写入收支记录页筛选偏好,并跳转到收支记录 * 将趋势时间桶与收支类型写入收支记录页筛选偏好,并跳转到收支记录
* *
@@ -390,12 +390,8 @@ function applyTrendNavigation(key: string, type: string | null) {
EXCLUDED: 'false', EXCLUDED: 'false',
ALL: null, ALL: null,
} }
try {
// 合并写入偏好,保留分页、排序等与本跳转无关的既有设置 // 合并写入偏好,保留分页、排序等与本跳转无关的既有设置
const raw = localStorage.getItem(TRANSACTION_FILTER_PREFS_KEY) saveTransactionFilterPrefs({
const existing = raw ? JSON.parse(raw) : {}
localStorage.setItem(TRANSACTION_FILTER_PREFS_KEY, JSON.stringify({
...existing,
filterType: type ? [type] : filterType.value, filterType: type ? [type] : filterType.value,
filterCategory: filterCategory.value, filterCategory: filterCategory.value,
filterChannel: filterChannel.value, filterChannel: filterChannel.value,
@@ -405,10 +401,7 @@ function applyTrendNavigation(key: string, type: string | null) {
filterMinAmount: filterMinAmount.value, filterMinAmount: filterMinAmount.value,
filterMaxAmount: filterMaxAmount.value, filterMaxAmount: filterMaxAmount.value,
filterRemark: filterRemark.value.trim(), filterRemark: filterRemark.value.trim(),
})) })
} catch (e) {
// 忽略写入失败,仍继续跳转
}
router.push('/transactions') router.push('/transactions')
} }
@@ -472,18 +465,18 @@ async function fetchData() {
} }
/** 面板偏好存储键 */ /** 面板偏好存储键 */
const DASHBOARD_PREFS_KEY = 'finance.dashboard.prefs' const DASHBOARD_PREFS_KEY = STORAGE_KEYS.dashboardPrefs
/** AI 查询历史与浮窗位置尺寸存储键 */
const AI_QUERY_HISTORY_KEY = STORAGE_KEYS.dashboardAiSearchHistory
const AI_QUERY_WINDOW_KEY = STORAGE_KEYS.dashboardAiQueryWindow
let suppressRangeWatch = false // 还原偏好时跳过 rangeType 对粒度的默认覆盖 let suppressRangeWatch = false // 还原偏好时跳过 rangeType 对粒度的默认覆盖
/** 读取面板偏好并还原(时间范围、粒度、过滤条件) */ /** 读取面板偏好并还原(时间范围、粒度、过滤条件) */
function loadPrefs() { function loadPrefs() {
try { const p = readJson<Record<string, any>>(DASHBOARD_PREFS_KEY, {})
const raw = localStorage.getItem(DASHBOARD_PREFS_KEY)
if (!raw) return
const p = JSON.parse(raw)
suppressRangeWatch = true suppressRangeWatch = true
if (['month', '3month', '12month', 'custom'].includes(p.rangeType)) rangeType.value = p.rangeType if (['month', '3month', '12month', 'custom'].includes(p.rangeType)) rangeType.value = p.rangeType
if (Array.isArray(p.customRange) && p.customRange.length === 2) customRange.value = p.customRange if (Array.isArray(p.customRange) && p.customRange.length === 2) customRange.value = p.customRange as [number, number]
if (['INCLUDED', 'ALL', 'EXCLUDED'].includes(p.filterStatsScope)) filterStatsScope.value = p.filterStatsScope if (['INCLUDED', 'ALL', 'EXCLUDED'].includes(p.filterStatsScope)) filterStatsScope.value = p.filterStatsScope
if (Array.isArray(p.filterType)) filterType.value = p.filterType if (Array.isArray(p.filterType)) filterType.value = p.filterType
if (Array.isArray(p.filterCategory)) filterCategory.value = p.filterCategory if (Array.isArray(p.filterCategory)) filterCategory.value = p.filterCategory
@@ -495,15 +488,11 @@ function loadPrefs() {
if (typeof p.filterMaxAmount === 'number') filterMaxAmount.value = p.filterMaxAmount if (typeof p.filterMaxAmount === 'number') filterMaxAmount.value = p.filterMaxAmount
if (typeof p.filterRemark === 'string') filterRemark.value = p.filterRemark if (typeof p.filterRemark === 'string') filterRemark.value = p.filterRemark
if (['day', 'week', 'month', 'quarter', 'year'].includes(p.granularity)) granularity.value = p.granularity if (['day', 'week', 'month', 'quarter', 'year'].includes(p.granularity)) granularity.value = p.granularity
} catch (e) {
// 忽略损坏的偏好数据
}
} }
/** 保存面板偏好,下次进入自动还原 */ /** 保存面板偏好,下次进入自动还原 */
function savePrefs() { function savePrefs() {
try { writeJson(DASHBOARD_PREFS_KEY, {
localStorage.setItem(DASHBOARD_PREFS_KEY, JSON.stringify({
rangeType: rangeType.value, rangeType: rangeType.value,
customRange: customRange.value, customRange: customRange.value,
granularity: granularity.value, granularity: granularity.value,
@@ -515,10 +504,7 @@ function savePrefs() {
filterMinAmount: filterMinAmount.value, filterMinAmount: filterMinAmount.value,
filterMaxAmount: filterMaxAmount.value, filterMaxAmount: filterMaxAmount.value,
filterRemark: filterRemark.value, filterRemark: filterRemark.value,
})) })
} catch (e) {
// 忽略写入失败
}
} }
/** 加载分类选项 */ /** 加载分类选项 */
@@ -897,8 +883,8 @@ onMounted(async () => {
:placeholder="t('dashboard.aiSearchPlaceholder')" :placeholder="t('dashboard.aiSearchPlaceholder')"
:examples="aiExamples" :examples="aiExamples"
:loading="aiQuerying" :loading="aiQuerying"
history-key="finance.dashboard.aiSearchHistory" :history-key="AI_QUERY_HISTORY_KEY"
storage-key="finance.dashboard.aiQueryWindow" :storage-key="AI_QUERY_WINDOW_KEY"
@submit="handleAiStatistics" @submit="handleAiStatistics"
/> />
</div> </div>
+6 -2
View File
@@ -29,6 +29,8 @@ import { renderCategoryLabel } from '../utils/render'
import BillPreviewPanel from '../components/BillPreviewPanel.vue' import BillPreviewPanel from '../components/BillPreviewPanel.vue'
import BillImportResult from '../components/BillImportResult.vue' import BillImportResult from '../components/BillImportResult.vue'
import { openSse } from '../utils/sse' import { openSse } from '../utils/sse'
import { migrateKey, writeString } from '../utils/storage'
import { LEGACY_STORAGE_KEYS, STORAGE_KEYS } from '../utils/storageKeys'
const message = useMessage() const message = useMessage()
const dialog = useDialog() const dialog = useDialog()
@@ -40,7 +42,9 @@ const router = useRouter()
const MESSAGE_REFRESH_MS = 30000 const MESSAGE_REFRESH_MS = 30000
// 标签页偏好存储键,与路由 /email-bills 的重定向逻辑保持一致 // 标签页偏好存储键,与路由 /email-bills 的重定向逻辑保持一致
const EMAIL_BILL_TAB_KEY = 'emailBill.tab' const EMAIL_BILL_TAB_KEY = STORAGE_KEYS.emailBillTab
// 首次进入时迁移旧键,保证老用户标签页偏好不丢失
migrateKey(LEGACY_STORAGE_KEYS.emailBillTab, EMAIL_BILL_TAB_KEY)
// 当前标签页取自 URL 子路径(/email-bills/configs、/senderRules、/messages) // 当前标签页取自 URL 子路径(/email-bills/configs、/senderRules、/messages)
const activeTab = ref<string>((route.params.tab as string) || 'configs') const activeTab = ref<string>((route.params.tab as string) || 'configs')
@@ -666,7 +670,7 @@ watch(() => route.params.tab, (tab) => {
// 标签页切换:记住偏好并同步 URL 子路径 // 标签页切换:记住偏好并同步 URL 子路径
watch(activeTab, (tab) => { watch(activeTab, (tab) => {
localStorage.setItem(EMAIL_BILL_TAB_KEY, tab) writeString(EMAIL_BILL_TAB_KEY, tab)
if ((route.params.tab as string) !== tab) { if ((route.params.tab as string) !== tab) {
router.push(`/email-bills/${tab}`) router.push(`/email-bills/${tab}`)
} }
+4 -16
View File
@@ -9,6 +9,8 @@ import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, Opti
import { useUserStore } from '../store/user' import { useUserStore } from '../store/user'
import LocaleSwitch from '../components/LocaleSwitch.vue' import LocaleSwitch from '../components/LocaleSwitch.vue'
import { getChannels } from '../api/transaction' import { getChannels } from '../api/transaction'
import { readSessionString, writeSessionString } from '../utils/storage'
import { scrollKey } from '../utils/storageKeys'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -80,7 +82,6 @@ function handleLogout() {
// 内容区滚动位置记忆:按路由分别记录,进入页面时自动恢复 // 内容区滚动位置记忆:按路由分别记录,进入页面时自动恢复
const contentRef = ref<InstanceType<typeof NLayoutContent> | null>(null) // 内容区滚动容器 const contentRef = ref<InstanceType<typeof NLayoutContent> | null>(null) // 内容区滚动容器
const SCROLL_KEY_PREFIX = 'finance.scroll.' // sessionStorage 存储键前缀
let scrollRaf = 0 // scroll 事件的 rAF 节流句柄 let scrollRaf = 0 // scroll 事件的 rAF 节流句柄
let restoreRaf = 0 // 恢复滚动位置的 rAF 轮询句柄 let restoreRaf = 0 // 恢复滚动位置的 rAF 轮询句柄
@@ -89,29 +90,16 @@ function getScrollEl(): HTMLElement | null {
return (contentRef.value as any)?.$el ?? null return (contentRef.value as any)?.$el ?? null
} }
/** 生成指定路由的滚动位置存储键 */
function scrollKey(path: string) {
return `${SCROLL_KEY_PREFIX}${path}`
}
/** 保存指定路由的滚动位置到 sessionStorage */ /** 保存指定路由的滚动位置到 sessionStorage */
function saveScroll(path: string = route.path) { function saveScroll(path: string = route.path) {
const el = getScrollEl() const el = getScrollEl()
if (!el) return if (!el) return
try { writeSessionString(scrollKey(path), String(el.scrollTop))
sessionStorage.setItem(scrollKey(path), String(el.scrollTop))
} catch (e) {
// 忽略存储异常(如隐私模式)
}
} }
/** 读取指定路由的滚动位置,无记录时返回 0 */ /** 读取指定路由的滚动位置,无记录时返回 0 */
function readScroll(path: string): number { function readScroll(path: string): number {
try { return Number(readSessionString(scrollKey(path))) || 0
return Number(sessionStorage.getItem(scrollKey(path))) || 0
} catch (e) {
return 0
}
} }
/** scroll 事件处理:rAF 节流,避免高频写入 sessionStorage */ /** scroll 事件处理:rAF 节流,避免高频写入 sessionStorage */
+6 -2
View File
@@ -12,6 +12,8 @@ import { RefreshOutline, SearchOutline } from '@vicons/ionicons5'
import { pageHttpLogs, pageLoginLogs, getAppLog, getLogRetention, updateLogRetention } from '../api/log' import { pageHttpLogs, pageLoginLogs, getAppLog, getLogRetention, updateLogRetention } from '../api/log'
import type { HttpLog, LoginLog, AppLog } from '../api/log' import type { HttpLog, LoginLog, AppLog } from '../api/log'
import { openSse } from '../utils/sse' import { openSse } from '../utils/sse'
import { migrateKey, writeString } from '../utils/storage'
import { LEGACY_STORAGE_KEYS, STORAGE_KEYS } from '../utils/storageKeys'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -22,7 +24,9 @@ const { t } = useI18n()
const activeTab = ref<string>((route.params.tab as string) || 'http') const activeTab = ref<string>((route.params.tab as string) || 'http')
// 标签页偏好存储键,与路由 /logs 的重定向逻辑保持一致 // 标签页偏好存储键,与路由 /logs 的重定向逻辑保持一致
const LOGS_TAB_KEY = 'logs.tab' const LOGS_TAB_KEY = STORAGE_KEYS.logsTab
// 首次进入时迁移旧键,保证老用户标签页偏好不丢失
migrateKey(LEGACY_STORAGE_KEYS.logsTab, LOGS_TAB_KEY)
// 日志保留天数(0 表示永久保留),默认 30 天 // 日志保留天数(0 表示永久保留),默认 30 天
const logRetention = ref(30) const logRetention = ref(30)
@@ -332,7 +336,7 @@ watch(() => route.params.tab, (tab) => {
// 标签页切换:记住偏好、加载对应数据、同步 URL 子路径、控制实时连接 // 标签页切换:记住偏好、加载对应数据、同步 URL 子路径、控制实时连接
watch(activeTab, (tab) => { watch(activeTab, (tab) => {
// 保存当前标签页偏好,供下次点击「系统日志」时恢复 // 保存当前标签页偏好,供下次点击「系统日志」时恢复
localStorage.setItem(LOGS_TAB_KEY, tab) writeString(LOGS_TAB_KEY, tab)
if (tab === 'http') { if (tab === 'http') {
loadHttp() loadHttp()
} else if (tab === 'login') { } else if (tab === 'login') {
+28 -68
View File
@@ -16,7 +16,10 @@ import { getTagList, createTag } from '../api/tag'
import { getChannelLabel } from '../utils/channel' import { getChannelLabel } from '../utils/channel'
import { renderCategoryLabel } from '../utils/render' import { renderCategoryLabel } from '../utils/render'
import { useKeywordHistory } from '../composables/useKeywordHistory' import { useKeywordHistory } from '../composables/useKeywordHistory'
import { useStringPreference } from '../composables/usePreference'
import { openSse } from '../utils/sse' import { openSse } from '../utils/sse'
import { STORAGE_KEYS } from '../utils/storageKeys'
import { loadTransactionFilterPrefs, saveTransactionFilterPrefs } from '../utils/transactionFilterPrefs'
import BillImportModal from './BillImportModal.vue' import BillImportModal from './BillImportModal.vue'
import TransactionDetailModal from '../components/TransactionDetailModal.vue' import TransactionDetailModal from '../components/TransactionDetailModal.vue'
import TransactionRelationEditorModal from '../components/TransactionRelationEditorModal.vue' import TransactionRelationEditorModal from '../components/TransactionRelationEditorModal.vue'
@@ -146,34 +149,18 @@ const aiEnabled = ref(false) // AI 建议是否启用
const aiLoading = ref(false) // AI 建议生成状态 const aiLoading = ref(false) // AI 建议生成状态
const aiFormLoading = ref(false) // 编辑弹窗内 AI 推荐状态 const aiFormLoading = ref(false) // 编辑弹窗内 AI 推荐状态
/** AI 建议范围偏好存储键 */ /** AI 建议范围偏好存储键 */
const AI_SCOPE_KEY = 'finance.transaction.aiScope' const AI_SCOPE_KEY = STORAGE_KEYS.transactionAiScope
/** 读取上次的 AI 建议范围偏好,非法值回退 BOTH */ // 批量 AI 建议范围:BOTH/CATEGORY/TAG(记忆用户偏好,非法值回退 BOTH)
function loadAiScope(): string { const aiScope = useStringPreference(AI_SCOPE_KEY, 'BOTH', {
try { validate: (value) => ['BOTH', 'CATEGORY', 'TAG'].includes(value),
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 建议范围选项 // 批量 AI 建议范围选项
const aiScopeOptions = computed(() => [ const aiScopeOptions = computed(() => [
{ label: t('ai.scope.both'), value: 'BOTH' }, { label: t('ai.scope.both'), value: 'BOTH' },
{ label: t('ai.scope.category'), value: 'CATEGORY' }, { label: t('ai.scope.category'), value: 'CATEGORY' },
{ label: t('ai.scope.tag'), value: 'TAG' }, { 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 aiNewLoading = ref(false) // 编辑弹窗内 AI 生成新分类状态
const aiNewSaving = ref(false) // 新分类保存状态 const aiNewSaving = ref(false) // 新分类保存状态
// AI 生成但尚未保存的新分类,需用户确认后再落库 // AI 生成但尚未保存的新分类,需用户确认后再落库
@@ -197,28 +184,16 @@ const showAiRecordModal = ref(false) // AI 记账弹窗显示状态
const aiRecordText = ref('') // AI 记账输入 const aiRecordText = ref('') // AI 记账输入
const aiRecording = ref(false) // AI 记账解析状态 const aiRecording = ref(false) // AI 记账解析状态
/** AI 记账方式偏好存储键(参考邮箱页 Tab 记忆) */ /** AI 记账方式偏好存储键(参考邮箱页 Tab 记忆) */
const AI_RECORD_TAB_KEY = 'finance.transaction.aiRecordTab' const AI_RECORD_TAB_KEY = STORAGE_KEYS.transactionAiRecordTab
/** 读取上次的 AI 记账方式偏好,非法值回退文字记账 */ // AI 记账方式:文字/图片(记忆用户偏好,非法值回退文字记账)
function loadAiRecordTab(): 'text' | 'image' { const aiRecordTab = useStringPreference(AI_RECORD_TAB_KEY, 'text', {
try { validate: (value) => value === 'text' || value === 'image',
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 AI_QUERY_HISTORY_KEY = STORAGE_KEYS.transactionAiQueryHistory
const AI_QUERY_WINDOW_KEY = STORAGE_KEYS.transactionAiQueryWindow
/** 图片记账单次最多上传的图片数量 */ /** 图片记账单次最多上传的图片数量 */
const MAX_AI_RECORD_IMAGES = 5 const MAX_AI_RECORD_IMAGES = 5
const aiRecordFileList = ref<UploadFileInfo[]>([]) // 图片记账上传文件列表 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 filterIncludeInStats = ref<string | null>(null) // 计入统计筛选:null-全部,'true'-计入,'false'-不计入
const filterRemark = ref('') // 备注关键字搜索 const filterRemark = ref('') // 备注关键字搜索
// 备注关键词历史存储键 // 备注关键词历史存储键
const REMARK_HISTORY_KEY = 'finance.transaction.remarkHistory' const REMARK_HISTORY_KEY = STORAGE_KEYS.transactionRemarkHistory
// 备注关键词历史(最近输入,最新在前) // 备注关键词历史(最近输入,最新在前)
const remarkHistory = useKeywordHistory({ const remarkHistory = useKeywordHistory({
storageKey: REMARK_HISTORY_KEY, storageKey: REMARK_HISTORY_KEY,
@@ -359,37 +334,25 @@ const includeInStatsOptions = computed(() => [
{ label: t('transaction.includeFilter.no'), value: 'false' }, { label: t('transaction.includeFilter.no'), value: 'false' },
]) ])
/** 筛选条件偏好存储键 */
const FILTER_PREFS_KEY = 'finance.transaction.filterPrefs'
/** 从 localStorage 读取上次的筛选条件偏好并还原 */ /** 从 localStorage 读取上次的筛选条件偏好并还原 */
function loadFilterPrefs() { function loadFilterPrefs() {
try { const p = loadTransactionFilterPrefs()
const raw = localStorage.getItem(FILTER_PREFS_KEY) if (p.filterType) filterType.value = p.filterType
if (!raw) return if (p.filterCategory) filterCategory.value = p.filterCategory
const p = JSON.parse(raw) if (p.filterChannel) filterChannel.value = p.filterChannel
if (Array.isArray(p.filterType)) filterType.value = p.filterType if (p.filterDateRange) filterDateRange.value = p.filterDateRange
if (Array.isArray(p.filterCategory)) filterCategory.value = p.filterCategory if (p.filterIncludeInStats) filterIncludeInStats.value = p.filterIncludeInStats
// 兼容旧版本以 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.filterRemark === 'string') filterRemark.value = p.filterRemark
if (typeof p.filterMinAmount === 'number') filterMinAmount.value = p.filterMinAmount if (typeof p.filterMinAmount === 'number') filterMinAmount.value = p.filterMinAmount
if (typeof p.filterMaxAmount === 'number') filterMaxAmount.value = p.filterMaxAmount 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.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 (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 if (p.filterTag) filterTag.value = p.filterTag
} catch (e) {
// 忽略损坏的偏好数据
}
} }
/** 将当前筛选条件写入 localStorage,下次进入自动还原 */ /** 将当前筛选条件写入 localStorage,下次进入自动还原 */
function saveFilterPrefs() { function saveFilterPrefs() {
try { saveTransactionFilterPrefs({
localStorage.setItem(FILTER_PREFS_KEY, JSON.stringify({
filterType: filterType.value, filterType: filterType.value,
filterCategory: filterCategory.value, filterCategory: filterCategory.value,
filterChannel: filterChannel.value, filterChannel: filterChannel.value,
@@ -401,10 +364,7 @@ function saveFilterPrefs() {
pageSize: pageSize.value, pageSize: pageSize.value,
sortValue: sortValue.value, sortValue: sortValue.value,
filterTag: filterTag.value, filterTag: filterTag.value,
})) })
} catch (e) {
// 忽略写入失败
}
} }
// 筛选条件变化时自动保存用户偏好 // 筛选条件变化时自动保存用户偏好
@@ -2532,8 +2492,8 @@ onBeforeUnmount(() => {
:placeholder="t('ai.query.placeholder')" :placeholder="t('ai.query.placeholder')"
:examples="aiQueryExamples" :examples="aiQueryExamples"
:loading="aiQuerying" :loading="aiQuerying"
history-key="finance.transaction.aiQueryHistory" :history-key="AI_QUERY_HISTORY_KEY"
storage-key="finance.transaction.aiQueryWindow" :storage-key="AI_QUERY_WINDOW_KEY"
@submit="handleAiQuery" @submit="handleAiQuery"
/> />
</div> </div>