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 { 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,25 +68,20 @@ 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 x = Number(parsed?.x)
|
||||||
const parsed = raw ? JSON.parse(raw) : null
|
const y = Number(parsed?.y)
|
||||||
const x = Number(parsed?.x)
|
const width = Number(parsed?.width)
|
||||||
const y = Number(parsed?.y)
|
const height = Number(parsed?.height)
|
||||||
const width = Number(parsed?.width)
|
if ([x, y, width, height].every((n) => Number.isFinite(n))) {
|
||||||
const height = Number(parsed?.height)
|
rect.value = {
|
||||||
if ([x, y, width, height].every((n) => Number.isFinite(n))) {
|
x,
|
||||||
rect.value = {
|
y,
|
||||||
x,
|
width: Math.max(props.minWidth, width),
|
||||||
y,
|
height: Math.max(props.minHeight, height),
|
||||||
width: Math.max(props.minWidth, width),
|
|
||||||
height: Math.max(props.minHeight, height),
|
|
||||||
}
|
|
||||||
clampRect()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
} catch {
|
clampRect()
|
||||||
// 忽略读取失败
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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,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)
|
history.value = Array.isArray(parsed)
|
||||||
const parsed = raw ? JSON.parse(raw) : []
|
? parsed.filter((item) => typeof item === 'string' && item.trim()).slice(0, max)
|
||||||
history.value = Array.isArray(parsed)
|
: []
|
||||||
? 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 {
|
|
||||||
// 忽略删除失败
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下拉选项:按当前输入过滤,历史非空时末尾追加「清空历史」
|
// 下拉选项:按当前输入过滤,历史非空时末尾追加「清空历史」
|
||||||
|
|||||||
@@ -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> 同步
|
// 国际化入口:由对照消息构建各语言包,维护当前语言、持久化与 <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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'}`
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 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)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 挂载时重置并加载账单类型(页面进入或弹窗打开时均会触发)
|
// 挂载时重置并加载账单类型(页面进入或弹窗打开时均会触发)
|
||||||
|
|||||||
@@ -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,25 +390,18 @@ function applyTrendNavigation(key: string, type: string | null) {
|
|||||||
EXCLUDED: 'false',
|
EXCLUDED: 'false',
|
||||||
ALL: null,
|
ALL: null,
|
||||||
}
|
}
|
||||||
try {
|
// 合并写入偏好,保留分页、排序等与本跳转无关的既有设置
|
||||||
// 合并写入偏好,保留分页、排序等与本跳转无关的既有设置
|
saveTransactionFilterPrefs({
|
||||||
const raw = localStorage.getItem(TRANSACTION_FILTER_PREFS_KEY)
|
filterType: type ? [type] : filterType.value,
|
||||||
const existing = raw ? JSON.parse(raw) : {}
|
filterCategory: filterCategory.value,
|
||||||
localStorage.setItem(TRANSACTION_FILTER_PREFS_KEY, JSON.stringify({
|
filterChannel: filterChannel.value,
|
||||||
...existing,
|
filterTag: filterTag.value,
|
||||||
filterType: type ? [type] : filterType.value,
|
filterDateRange: bucketRange(key, granularity.value),
|
||||||
filterCategory: filterCategory.value,
|
filterIncludeInStats: statsScopeMap[filterStatsScope.value] ?? null,
|
||||||
filterChannel: filterChannel.value,
|
filterMinAmount: filterMinAmount.value,
|
||||||
filterTag: filterTag.value,
|
filterMaxAmount: filterMaxAmount.value,
|
||||||
filterDateRange: bucketRange(key, granularity.value),
|
filterRemark: filterRemark.value.trim(),
|
||||||
filterIncludeInStats: statsScopeMap[filterStatsScope.value] ?? null,
|
})
|
||||||
filterMinAmount: filterMinAmount.value,
|
|
||||||
filterMaxAmount: filterMaxAmount.value,
|
|
||||||
filterRemark: filterRemark.value.trim(),
|
|
||||||
}))
|
|
||||||
} catch (e) {
|
|
||||||
// 忽略写入失败,仍继续跳转
|
|
||||||
}
|
|
||||||
router.push('/transactions')
|
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 对粒度的默认覆盖
|
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)
|
suppressRangeWatch = true
|
||||||
if (!raw) return
|
if (['month', '3month', '12month', 'custom'].includes(p.rangeType)) rangeType.value = p.rangeType
|
||||||
const p = JSON.parse(raw)
|
if (Array.isArray(p.customRange) && p.customRange.length === 2) customRange.value = p.customRange as [number, number]
|
||||||
suppressRangeWatch = true
|
if (['INCLUDED', 'ALL', 'EXCLUDED'].includes(p.filterStatsScope)) filterStatsScope.value = p.filterStatsScope
|
||||||
if (['month', '3month', '12month', 'custom'].includes(p.rangeType)) rangeType.value = p.rangeType
|
if (Array.isArray(p.filterType)) filterType.value = p.filterType
|
||||||
if (Array.isArray(p.customRange) && p.customRange.length === 2) customRange.value = p.customRange
|
if (Array.isArray(p.filterCategory)) filterCategory.value = p.filterCategory
|
||||||
if (['INCLUDED', 'ALL', 'EXCLUDED'].includes(p.filterStatsScope)) filterStatsScope.value = p.filterStatsScope
|
// 兼容旧版本以 filterPaymentMethod 存储的偏好
|
||||||
if (Array.isArray(p.filterType)) filterType.value = p.filterType
|
const savedChannel = Array.isArray(p.filterChannel) ? p.filterChannel : p.filterPaymentMethod
|
||||||
if (Array.isArray(p.filterCategory)) filterCategory.value = p.filterCategory
|
if (Array.isArray(savedChannel)) filterChannel.value = savedChannel
|
||||||
// 兼容旧版本以 filterPaymentMethod 存储的偏好
|
if (Array.isArray(p.filterTag)) filterTag.value = p.filterTag
|
||||||
const savedChannel = Array.isArray(p.filterChannel) ? p.filterChannel : p.filterPaymentMethod
|
if (typeof p.filterMinAmount === 'number') filterMinAmount.value = p.filterMinAmount
|
||||||
if (Array.isArray(savedChannel)) filterChannel.value = savedChannel
|
if (typeof p.filterMaxAmount === 'number') filterMaxAmount.value = p.filterMaxAmount
|
||||||
if (Array.isArray(p.filterTag)) filterTag.value = p.filterTag
|
if (typeof p.filterRemark === 'string') filterRemark.value = p.filterRemark
|
||||||
if (typeof p.filterMinAmount === 'number') filterMinAmount.value = p.filterMinAmount
|
if (['day', 'week', 'month', 'quarter', 'year'].includes(p.granularity)) granularity.value = p.granularity
|
||||||
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) {
|
|
||||||
// 忽略损坏的偏好数据
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保存面板偏好,下次进入自动还原 */
|
/** 保存面板偏好,下次进入自动还原 */
|
||||||
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,
|
filterStatsScope: filterStatsScope.value,
|
||||||
filterStatsScope: filterStatsScope.value,
|
filterType: filterType.value,
|
||||||
filterType: filterType.value,
|
filterCategory: filterCategory.value,
|
||||||
filterCategory: filterCategory.value,
|
filterChannel: filterChannel.value,
|
||||||
filterChannel: filterChannel.value,
|
filterTag: filterTag.value,
|
||||||
filterTag: filterTag.value,
|
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>
|
||||||
|
|||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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,52 +334,37 @@ 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 存储的偏好
|
if (typeof p.filterRemark === 'string') filterRemark.value = p.filterRemark
|
||||||
const savedChannel = Array.isArray(p.filterChannel) ? p.filterChannel : p.filterPaymentMethod
|
if (typeof p.filterMinAmount === 'number') filterMinAmount.value = p.filterMinAmount
|
||||||
if (Array.isArray(savedChannel)) filterChannel.value = savedChannel
|
if (typeof p.filterMaxAmount === 'number') filterMaxAmount.value = p.filterMaxAmount
|
||||||
if (Array.isArray(p.filterDateRange) && p.filterDateRange.length === 2) filterDateRange.value = p.filterDateRange
|
if (typeof p.pageSize === 'number' && pageSizes.includes(p.pageSize)) pageSize.value = p.pageSize
|
||||||
if (p.filterIncludeInStats === 'true' || p.filterIncludeInStats === 'false') filterIncludeInStats.value = p.filterIncludeInStats
|
if (typeof p.sortValue === 'string' && sortOptions.value.some((o) => o.value === p.sortValue)) sortValue.value = p.sortValue
|
||||||
if (typeof p.filterRemark === 'string') filterRemark.value = p.filterRemark
|
if (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.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) {
|
|
||||||
// 忽略损坏的偏好数据
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将当前筛选条件写入 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,
|
filterDateRange: filterDateRange.value,
|
||||||
filterDateRange: filterDateRange.value,
|
filterIncludeInStats: filterIncludeInStats.value,
|
||||||
filterIncludeInStats: filterIncludeInStats.value,
|
filterRemark: filterRemark.value,
|
||||||
filterRemark: filterRemark.value,
|
filterMinAmount: filterMinAmount.value,
|
||||||
filterMinAmount: filterMinAmount.value,
|
filterMaxAmount: filterMaxAmount.value,
|
||||||
filterMaxAmount: filterMaxAmount.value,
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user