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:
@@ -3,6 +3,7 @@
|
||||
import { ref, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { NButton, NIcon } from 'naive-ui'
|
||||
import { CloseOutline } from '@vicons/ionicons5'
|
||||
import { readJson, writeJson } from '../utils/storage'
|
||||
|
||||
/** 浮窗位置与尺寸 */
|
||||
interface PanelRect {
|
||||
@@ -67,25 +68,20 @@ function clampRect() {
|
||||
/** 读取持久化的位置与尺寸,非法值回退默认并居中 */
|
||||
function loadRect() {
|
||||
if (props.storageKey) {
|
||||
try {
|
||||
const raw = localStorage.getItem(props.storageKey)
|
||||
const parsed = raw ? JSON.parse(raw) : null
|
||||
const x = Number(parsed?.x)
|
||||
const y = Number(parsed?.y)
|
||||
const width = Number(parsed?.width)
|
||||
const height = Number(parsed?.height)
|
||||
if ([x, y, width, height].every((n) => Number.isFinite(n))) {
|
||||
rect.value = {
|
||||
x,
|
||||
y,
|
||||
width: Math.max(props.minWidth, width),
|
||||
height: Math.max(props.minHeight, height),
|
||||
}
|
||||
clampRect()
|
||||
return
|
||||
const parsed = readJson<Partial<PanelRect> | null>(props.storageKey, null)
|
||||
const x = Number(parsed?.x)
|
||||
const y = Number(parsed?.y)
|
||||
const width = Number(parsed?.width)
|
||||
const height = Number(parsed?.height)
|
||||
if ([x, y, width, height].every((n) => Number.isFinite(n))) {
|
||||
rect.value = {
|
||||
x,
|
||||
y,
|
||||
width: Math.max(props.minWidth, width),
|
||||
height: Math.max(props.minHeight, height),
|
||||
}
|
||||
} catch {
|
||||
// 忽略读取失败
|
||||
clampRect()
|
||||
return
|
||||
}
|
||||
}
|
||||
rect.value = defaultRect()
|
||||
@@ -94,11 +90,7 @@ function loadRect() {
|
||||
/** 持久化位置与尺寸,忽略存储异常 */
|
||||
function saveRect() {
|
||||
if (!props.storageKey) return
|
||||
try {
|
||||
localStorage.setItem(props.storageKey, JSON.stringify(rect.value))
|
||||
} catch {
|
||||
// 忽略写入失败
|
||||
}
|
||||
writeJson(props.storageKey, rect.value)
|
||||
}
|
||||
|
||||
// 拖拽状态
|
||||
|
||||
@@ -9,9 +9,11 @@ import type { TransactionGroup, TransactionRelationType, TransactionGroupPayload
|
||||
import { getTransactionPage } from '../api/transaction'
|
||||
import { getAiConfig, parseRelationByAi } from '../api/ai'
|
||||
import { getChannelLabel } from '../utils/channel'
|
||||
import { readString, writeString } from '../utils/storage'
|
||||
import { STORAGE_KEYS } from '../utils/storageKeys'
|
||||
|
||||
/** 关系类型偏好存储键(界面偏好统一以 finance. 前缀命名) */
|
||||
const RELATION_TYPE_KEY = 'finance.transaction.relationType'
|
||||
const RELATION_TYPE_KEY = STORAGE_KEYS.transactionRelationType
|
||||
|
||||
const props = defineProps<{
|
||||
/** 弹窗显示状态 */
|
||||
@@ -79,21 +81,13 @@ onMounted(loadAiConfig)
|
||||
|
||||
/** 读取上次使用的关系类型偏好,非法值或异常回退 PARENT_CHILD */
|
||||
function loadPreferredRelationType(): TransactionRelationType {
|
||||
try {
|
||||
const saved = localStorage.getItem(RELATION_TYPE_KEY)
|
||||
return saved === 'PARENT_CHILD' || saved === 'SIBLING' ? saved : 'PARENT_CHILD'
|
||||
} catch (e) {
|
||||
return 'PARENT_CHILD'
|
||||
}
|
||||
const saved = readString(RELATION_TYPE_KEY)
|
||||
return saved === 'PARENT_CHILD' || saved === 'SIBLING' ? saved : 'PARENT_CHILD'
|
||||
}
|
||||
|
||||
/** 保存关系类型偏好,忽略存储异常 */
|
||||
function savePreferredRelationType(type: TransactionRelationType) {
|
||||
try {
|
||||
localStorage.setItem(RELATION_TYPE_KEY, type)
|
||||
} catch (e) {
|
||||
// 忽略存储异常
|
||||
}
|
||||
writeString(RELATION_TYPE_KEY, type)
|
||||
}
|
||||
|
||||
/** 加载关系名称历史(按当前关系类型过滤) */
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Ref } from 'vue'
|
||||
import type { AutoCompleteOption } from 'naive-ui'
|
||||
import { NIcon } from 'naive-ui'
|
||||
import { CloseCircleOutline } from '@vicons/ionicons5'
|
||||
import { readJson, removeKey, writeJson } from '../utils/storage'
|
||||
|
||||
/** 关键词历史配置 */
|
||||
export interface KeywordHistoryConfig {
|
||||
@@ -30,15 +31,10 @@ export function useKeywordHistory(config: KeywordHistoryConfig) {
|
||||
|
||||
/** 从 localStorage 读取历史,忽略损坏数据 */
|
||||
function load() {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey)
|
||||
const parsed = raw ? JSON.parse(raw) : []
|
||||
history.value = Array.isArray(parsed)
|
||||
? parsed.filter((item) => typeof item === 'string' && item.trim()).slice(0, max)
|
||||
: []
|
||||
} catch {
|
||||
history.value = []
|
||||
}
|
||||
const parsed = readJson<unknown>(storageKey, [])
|
||||
history.value = Array.isArray(parsed)
|
||||
? parsed.filter((item) => typeof item === 'string' && item.trim()).slice(0, max)
|
||||
: []
|
||||
}
|
||||
|
||||
/** 记录当前关键词:去重(忽略大小写)、最新在前、上限截断 */
|
||||
@@ -47,21 +43,13 @@ export function useKeywordHistory(config: KeywordHistoryConfig) {
|
||||
if (!value) return
|
||||
const rest = history.value.filter((item) => item.toLowerCase() !== value.toLowerCase())
|
||||
history.value = [value, ...rest].slice(0, max)
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(history.value))
|
||||
} catch {
|
||||
// 忽略写入失败
|
||||
}
|
||||
writeJson(storageKey, history.value)
|
||||
}
|
||||
|
||||
/** 清空历史 */
|
||||
function clear() {
|
||||
history.value = []
|
||||
try {
|
||||
localStorage.removeItem(storageKey)
|
||||
} catch {
|
||||
// 忽略删除失败
|
||||
}
|
||||
removeKey(storageKey)
|
||||
}
|
||||
|
||||
// 下拉选项:按当前输入过滤,历史非空时末尾追加「清空历史」
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
// 国际化入口:由对照消息构建各语言包,维护当前语言、持久化与 <html lang> 同步
|
||||
import { createI18n } from 'vue-i18n'
|
||||
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'
|
||||
@@ -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']
|
||||
|
||||
@@ -40,7 +42,9 @@ function buildLocaleMessages(node: unknown, lang: 'zh' | 'en' | 'ja'): any {
|
||||
|
||||
/** 解析初始语言:localStorage > 浏览器语言 > 简体中文 */
|
||||
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)) {
|
||||
return saved as LocaleKey
|
||||
}
|
||||
@@ -74,7 +78,7 @@ export function getLocale(): LocaleKey {
|
||||
/** 切换语言:更新 i18n、持久化到 localStorage,并同步 <html lang> */
|
||||
export function setLocale(locale: LocaleKey) {
|
||||
i18n.global.locale.value = locale
|
||||
localStorage.setItem(STORAGE_KEY, locale)
|
||||
writeString(STORAGE_KEY, locale)
|
||||
document.documentElement.lang = locale
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useUserStore } from '../store/user'
|
||||
import { useDatabaseStore } from '../store/database'
|
||||
import { useRegistrationStore } from '../store/registration'
|
||||
import { migrateKey, readString } from '../utils/storage'
|
||||
import { LEGACY_STORAGE_KEYS, STORAGE_KEYS } from '../utils/storageKeys'
|
||||
|
||||
// 创建路由实例,使用 HTML5 History 模式
|
||||
const router = createRouter({
|
||||
@@ -68,7 +70,9 @@ const router = createRouter({
|
||||
// 跳转到上次访问的标签页,无记录时默认邮箱配置
|
||||
redirect: () => {
|
||||
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'}`
|
||||
},
|
||||
},
|
||||
@@ -92,7 +96,9 @@ const router = createRouter({
|
||||
// 跳转到上次访问的日志标签页,无记录时默认访问日志
|
||||
redirect: () => {
|
||||
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'}`
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,17 +2,21 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getUserInfo } from '../api/user'
|
||||
import { readString, removeKey, writeString } from '../utils/storage'
|
||||
|
||||
/** 登录令牌存储键(认证凭据,不属于界面偏好,不纳入 finance. 前缀) */
|
||||
const TOKEN_KEY = 'token'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// 从 localStorage 恢复 token,若不存在则为空字符串
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
const token = ref(readString(TOKEN_KEY))
|
||||
const nickname = ref('') // 用户昵称
|
||||
const userId = ref<number | null>(null) // 用户 ID
|
||||
|
||||
/** 设置 token 并持久化到 localStorage */
|
||||
function setToken(t: string) {
|
||||
token.value = t
|
||||
localStorage.setItem('token', t)
|
||||
writeString(TOKEN_KEY, t)
|
||||
}
|
||||
|
||||
/** 设置用户基本信息(昵称和 ID) */
|
||||
@@ -36,7 +40,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
token.value = ''
|
||||
nickname.value = ''
|
||||
userId.value = null
|
||||
localStorage.removeItem('token')
|
||||
removeKey(TOKEN_KEY)
|
||||
}
|
||||
|
||||
return { token, nickname, userId, setToken, setUserInfo, fetchUserInfo, logout }
|
||||
|
||||
@@ -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) {
|
||||
// 忽略迁移失败,保持旧键可用
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
import type { UploadFileInfo } from 'naive-ui'
|
||||
import { CloudUploadOutline } from '@vicons/ionicons5'
|
||||
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 BillImportResult from '../components/BillImportResult.vue'
|
||||
import { useStringPreference } from '../composables/usePreference'
|
||||
import { STORAGE_KEYS } from '../utils/storageKeys'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
@@ -22,7 +24,7 @@ const message = useMessage()
|
||||
const { t } = useI18n() // 国际化
|
||||
|
||||
// 账单类型偏好存储键
|
||||
const BILL_TYPE_PREFS_KEY = 'finance.billImport.billType'
|
||||
const BILL_TYPE_PREFS_KEY = STORAGE_KEYS.billImportBillType
|
||||
|
||||
// 后端下发的账单类型(编码 + 允许的扩展名),组件挂载时加载
|
||||
const billTypes = ref<BillTypeOption[]>([])
|
||||
@@ -58,13 +60,8 @@ const billTypeOptions = computed(() =>
|
||||
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 压缩包)
|
||||
const allowedExtensions = computed<Record<string, string[]>>(() => {
|
||||
@@ -97,8 +94,7 @@ async function loadBillTypes() {
|
||||
const res: any = await getBillTypes()
|
||||
billTypes.value = res.data || []
|
||||
const codes = billTypes.value.map((item) => item.code)
|
||||
const saved = readBillTypePref()
|
||||
billType.value = codes.includes(saved) ? saved : (codes[0] || '')
|
||||
billType.value = codes.includes(billType.value) ? billType.value : (codes[0] || '')
|
||||
} catch (e) {
|
||||
// 静默处理,账单类型下拉保持为空
|
||||
}
|
||||
@@ -222,11 +218,10 @@ function handleClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 切换账单类型时清空已选文件、记录偏好,避免文件格式与类型不匹配
|
||||
watch(billType, (value) => {
|
||||
// 切换账单类型时清空已选文件,避免文件格式与类型不匹配(偏好由 useStringPreference 自动持久化)
|
||||
watch(billType, () => {
|
||||
uploadFileList.value = []
|
||||
file.value = null
|
||||
localStorage.setItem(BILL_TYPE_PREFS_KEY, value)
|
||||
})
|
||||
|
||||
// 挂载时重置并加载账单类型(页面进入或弹窗打开时均会触发)
|
||||
|
||||
@@ -13,6 +13,9 @@ import { getAiConfig, parseStatisticsByAi } from '../api/ai'
|
||||
import type { AiStatisticsResult } from '../api/ai'
|
||||
import { getChannelLabel } from '../utils/channel'
|
||||
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'
|
||||
|
||||
// 图表组件异步加载:echarts 体积较大,登录后进入面板时先渲染页面骨架,
|
||||
@@ -374,9 +377,6 @@ function bucketRange(key: string, gran: string): [number, number] {
|
||||
return [startOfDay(y, 0, 1), endOfDay(y, 11, 31)]
|
||||
}
|
||||
|
||||
/** 收支记录页筛选偏好存储键(与 TransactionList 保持一致) */
|
||||
const TRANSACTION_FILTER_PREFS_KEY = 'finance.transaction.filterPrefs'
|
||||
|
||||
/**
|
||||
* 将趋势时间桶与收支类型写入收支记录页筛选偏好,并跳转到收支记录
|
||||
*
|
||||
@@ -390,25 +390,18 @@ function applyTrendNavigation(key: string, type: string | null) {
|
||||
EXCLUDED: 'false',
|
||||
ALL: null,
|
||||
}
|
||||
try {
|
||||
// 合并写入偏好,保留分页、排序等与本跳转无关的既有设置
|
||||
const raw = localStorage.getItem(TRANSACTION_FILTER_PREFS_KEY)
|
||||
const existing = raw ? JSON.parse(raw) : {}
|
||||
localStorage.setItem(TRANSACTION_FILTER_PREFS_KEY, JSON.stringify({
|
||||
...existing,
|
||||
filterType: type ? [type] : filterType.value,
|
||||
filterCategory: filterCategory.value,
|
||||
filterChannel: filterChannel.value,
|
||||
filterTag: filterTag.value,
|
||||
filterDateRange: bucketRange(key, granularity.value),
|
||||
filterIncludeInStats: statsScopeMap[filterStatsScope.value] ?? null,
|
||||
filterMinAmount: filterMinAmount.value,
|
||||
filterMaxAmount: filterMaxAmount.value,
|
||||
filterRemark: filterRemark.value.trim(),
|
||||
}))
|
||||
} catch (e) {
|
||||
// 忽略写入失败,仍继续跳转
|
||||
}
|
||||
// 合并写入偏好,保留分页、排序等与本跳转无关的既有设置
|
||||
saveTransactionFilterPrefs({
|
||||
filterType: type ? [type] : filterType.value,
|
||||
filterCategory: filterCategory.value,
|
||||
filterChannel: filterChannel.value,
|
||||
filterTag: filterTag.value,
|
||||
filterDateRange: bucketRange(key, granularity.value),
|
||||
filterIncludeInStats: statsScopeMap[filterStatsScope.value] ?? null,
|
||||
filterMinAmount: filterMinAmount.value,
|
||||
filterMaxAmount: filterMaxAmount.value,
|
||||
filterRemark: filterRemark.value.trim(),
|
||||
})
|
||||
router.push('/transactions')
|
||||
}
|
||||
|
||||
@@ -472,53 +465,46 @@ 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 对粒度的默认覆盖
|
||||
|
||||
/** 读取面板偏好并还原(时间范围、粒度、过滤条件) */
|
||||
function loadPrefs() {
|
||||
try {
|
||||
const raw = localStorage.getItem(DASHBOARD_PREFS_KEY)
|
||||
if (!raw) return
|
||||
const p = JSON.parse(raw)
|
||||
suppressRangeWatch = true
|
||||
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 (['INCLUDED', 'ALL', 'EXCLUDED'].includes(p.filterStatsScope)) filterStatsScope.value = p.filterStatsScope
|
||||
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.filterTag)) filterTag.value = p.filterTag
|
||||
if (typeof p.filterMinAmount === 'number') filterMinAmount.value = p.filterMinAmount
|
||||
if (typeof p.filterMaxAmount === 'number') filterMaxAmount.value = p.filterMaxAmount
|
||||
if (typeof p.filterRemark === 'string') filterRemark.value = p.filterRemark
|
||||
if (['day', 'week', 'month', 'quarter', 'year'].includes(p.granularity)) granularity.value = p.granularity
|
||||
} catch (e) {
|
||||
// 忽略损坏的偏好数据
|
||||
}
|
||||
const p = readJson<Record<string, any>>(DASHBOARD_PREFS_KEY, {})
|
||||
suppressRangeWatch = true
|
||||
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 as [number, number]
|
||||
if (['INCLUDED', 'ALL', 'EXCLUDED'].includes(p.filterStatsScope)) filterStatsScope.value = p.filterStatsScope
|
||||
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.filterTag)) filterTag.value = p.filterTag
|
||||
if (typeof p.filterMinAmount === 'number') filterMinAmount.value = p.filterMinAmount
|
||||
if (typeof p.filterMaxAmount === 'number') filterMaxAmount.value = p.filterMaxAmount
|
||||
if (typeof p.filterRemark === 'string') filterRemark.value = p.filterRemark
|
||||
if (['day', 'week', 'month', 'quarter', 'year'].includes(p.granularity)) granularity.value = p.granularity
|
||||
}
|
||||
|
||||
/** 保存面板偏好,下次进入自动还原 */
|
||||
function savePrefs() {
|
||||
try {
|
||||
localStorage.setItem(DASHBOARD_PREFS_KEY, JSON.stringify({
|
||||
rangeType: rangeType.value,
|
||||
customRange: customRange.value,
|
||||
granularity: granularity.value,
|
||||
filterStatsScope: filterStatsScope.value,
|
||||
filterType: filterType.value,
|
||||
filterCategory: filterCategory.value,
|
||||
filterChannel: filterChannel.value,
|
||||
filterTag: filterTag.value,
|
||||
filterMinAmount: filterMinAmount.value,
|
||||
filterMaxAmount: filterMaxAmount.value,
|
||||
filterRemark: filterRemark.value,
|
||||
}))
|
||||
} catch (e) {
|
||||
// 忽略写入失败
|
||||
}
|
||||
writeJson(DASHBOARD_PREFS_KEY, {
|
||||
rangeType: rangeType.value,
|
||||
customRange: customRange.value,
|
||||
granularity: granularity.value,
|
||||
filterStatsScope: filterStatsScope.value,
|
||||
filterType: filterType.value,
|
||||
filterCategory: filterCategory.value,
|
||||
filterChannel: filterChannel.value,
|
||||
filterTag: filterTag.value,
|
||||
filterMinAmount: filterMinAmount.value,
|
||||
filterMaxAmount: filterMaxAmount.value,
|
||||
filterRemark: filterRemark.value,
|
||||
})
|
||||
}
|
||||
|
||||
/** 加载分类选项 */
|
||||
@@ -897,8 +883,8 @@ onMounted(async () => {
|
||||
:placeholder="t('dashboard.aiSearchPlaceholder')"
|
||||
:examples="aiExamples"
|
||||
:loading="aiQuerying"
|
||||
history-key="finance.dashboard.aiSearchHistory"
|
||||
storage-key="finance.dashboard.aiQueryWindow"
|
||||
:history-key="AI_QUERY_HISTORY_KEY"
|
||||
:storage-key="AI_QUERY_WINDOW_KEY"
|
||||
@submit="handleAiStatistics"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,8 @@ import { renderCategoryLabel } from '../utils/render'
|
||||
import BillPreviewPanel from '../components/BillPreviewPanel.vue'
|
||||
import BillImportResult from '../components/BillImportResult.vue'
|
||||
import { openSse } from '../utils/sse'
|
||||
import { migrateKey, writeString } from '../utils/storage'
|
||||
import { LEGACY_STORAGE_KEYS, STORAGE_KEYS } from '../utils/storageKeys'
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
@@ -40,7 +42,9 @@ const router = useRouter()
|
||||
const MESSAGE_REFRESH_MS = 30000
|
||||
|
||||
// 标签页偏好存储键,与路由 /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)
|
||||
const activeTab = ref<string>((route.params.tab as string) || 'configs')
|
||||
@@ -666,7 +670,7 @@ watch(() => route.params.tab, (tab) => {
|
||||
|
||||
// 标签页切换:记住偏好并同步 URL 子路径
|
||||
watch(activeTab, (tab) => {
|
||||
localStorage.setItem(EMAIL_BILL_TAB_KEY, tab)
|
||||
writeString(EMAIL_BILL_TAB_KEY, tab)
|
||||
if ((route.params.tab as string) !== tab) {
|
||||
router.push(`/email-bills/${tab}`)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, Opti
|
||||
import { useUserStore } from '../store/user'
|
||||
import LocaleSwitch from '../components/LocaleSwitch.vue'
|
||||
import { getChannels } from '../api/transaction'
|
||||
import { readSessionString, writeSessionString } from '../utils/storage'
|
||||
import { scrollKey } from '../utils/storageKeys'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -80,7 +82,6 @@ function handleLogout() {
|
||||
|
||||
// 内容区滚动位置记忆:按路由分别记录,进入页面时自动恢复
|
||||
const contentRef = ref<InstanceType<typeof NLayoutContent> | null>(null) // 内容区滚动容器
|
||||
const SCROLL_KEY_PREFIX = 'finance.scroll.' // sessionStorage 存储键前缀
|
||||
let scrollRaf = 0 // scroll 事件的 rAF 节流句柄
|
||||
let restoreRaf = 0 // 恢复滚动位置的 rAF 轮询句柄
|
||||
|
||||
@@ -89,29 +90,16 @@ function getScrollEl(): HTMLElement | null {
|
||||
return (contentRef.value as any)?.$el ?? null
|
||||
}
|
||||
|
||||
/** 生成指定路由的滚动位置存储键 */
|
||||
function scrollKey(path: string) {
|
||||
return `${SCROLL_KEY_PREFIX}${path}`
|
||||
}
|
||||
|
||||
/** 保存指定路由的滚动位置到 sessionStorage */
|
||||
function saveScroll(path: string = route.path) {
|
||||
const el = getScrollEl()
|
||||
if (!el) return
|
||||
try {
|
||||
sessionStorage.setItem(scrollKey(path), String(el.scrollTop))
|
||||
} catch (e) {
|
||||
// 忽略存储异常(如隐私模式)
|
||||
}
|
||||
writeSessionString(scrollKey(path), String(el.scrollTop))
|
||||
}
|
||||
|
||||
/** 读取指定路由的滚动位置,无记录时返回 0 */
|
||||
function readScroll(path: string): number {
|
||||
try {
|
||||
return Number(sessionStorage.getItem(scrollKey(path))) || 0
|
||||
} catch (e) {
|
||||
return 0
|
||||
}
|
||||
return Number(readSessionString(scrollKey(path))) || 0
|
||||
}
|
||||
|
||||
/** scroll 事件处理:rAF 节流,避免高频写入 sessionStorage */
|
||||
|
||||
@@ -12,6 +12,8 @@ import { RefreshOutline, SearchOutline } from '@vicons/ionicons5'
|
||||
import { pageHttpLogs, pageLoginLogs, getAppLog, getLogRetention, updateLogRetention } from '../api/log'
|
||||
import type { HttpLog, LoginLog, AppLog } from '../api/log'
|
||||
import { openSse } from '../utils/sse'
|
||||
import { migrateKey, writeString } from '../utils/storage'
|
||||
import { LEGACY_STORAGE_KEYS, STORAGE_KEYS } from '../utils/storageKeys'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -22,7 +24,9 @@ const { t } = useI18n()
|
||||
const activeTab = ref<string>((route.params.tab as string) || 'http')
|
||||
|
||||
// 标签页偏好存储键,与路由 /logs 的重定向逻辑保持一致
|
||||
const LOGS_TAB_KEY = 'logs.tab'
|
||||
const LOGS_TAB_KEY = STORAGE_KEYS.logsTab
|
||||
// 首次进入时迁移旧键,保证老用户标签页偏好不丢失
|
||||
migrateKey(LEGACY_STORAGE_KEYS.logsTab, LOGS_TAB_KEY)
|
||||
|
||||
// 日志保留天数(0 表示永久保留),默认 30 天
|
||||
const logRetention = ref(30)
|
||||
@@ -332,7 +336,7 @@ watch(() => route.params.tab, (tab) => {
|
||||
// 标签页切换:记住偏好、加载对应数据、同步 URL 子路径、控制实时连接
|
||||
watch(activeTab, (tab) => {
|
||||
// 保存当前标签页偏好,供下次点击「系统日志」时恢复
|
||||
localStorage.setItem(LOGS_TAB_KEY, tab)
|
||||
writeString(LOGS_TAB_KEY, tab)
|
||||
if (tab === 'http') {
|
||||
loadHttp()
|
||||
} else if (tab === 'login') {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user