From eb358d0ba173bcf073588e6bd911cca84b0b4e22 Mon Sep 17 00:00:00 2001 From: rosercode Date: Wed, 23 Sep 2026 09:41:55 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=89=8D=E7=AB=AF=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=81=8F=E5=A5=BD=E7=BB=9F=E4=B8=80=E4=B8=BA=E5=85=AC?= =?UTF-8?q?=E5=85=B1=E5=AD=98=E5=82=A8=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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. 前缀 --- frontend/src/components/FloatingPanel.vue | 38 ++---- .../TransactionRelationEditorModal.vue | 18 +-- frontend/src/composables/useKeywordHistory.ts | 26 +--- frontend/src/composables/usePreference.ts | 36 +++++ frontend/src/locales/index.ts | 10 +- frontend/src/router/index.ts | 10 +- frontend/src/store/user.ts | 10 +- frontend/src/utils/storage.ts | 83 ++++++++++++ frontend/src/utils/storageKeys.ts | 47 +++++++ frontend/src/utils/transactionFilterPrefs.ts | 63 +++++++++ frontend/src/views/BillImportPanel.vue | 21 ++- frontend/src/views/Dashboard.vue | 112 +++++++-------- frontend/src/views/EmailBillManage.vue | 8 +- frontend/src/views/Layout.vue | 20 +-- frontend/src/views/Logs.vue | 8 +- frontend/src/views/TransactionList.vue | 128 ++++++------------ 16 files changed, 396 insertions(+), 242 deletions(-) create mode 100644 frontend/src/composables/usePreference.ts create mode 100644 frontend/src/utils/storage.ts create mode 100644 frontend/src/utils/storageKeys.ts create mode 100644 frontend/src/utils/transactionFilterPrefs.ts diff --git a/frontend/src/components/FloatingPanel.vue b/frontend/src/components/FloatingPanel.vue index 7fe0769..d05ab29 100644 --- a/frontend/src/components/FloatingPanel.vue +++ b/frontend/src/components/FloatingPanel.vue @@ -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 | 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) } // 拖拽状态 diff --git a/frontend/src/components/TransactionRelationEditorModal.vue b/frontend/src/components/TransactionRelationEditorModal.vue index fc45049..a4f83cf 100644 --- a/frontend/src/components/TransactionRelationEditorModal.vue +++ b/frontend/src/components/TransactionRelationEditorModal.vue @@ -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) } /** 加载关系名称历史(按当前关系类型过滤) */ diff --git a/frontend/src/composables/useKeywordHistory.ts b/frontend/src/composables/useKeywordHistory.ts index d27741c..87fb562 100644 --- a/frontend/src/composables/useKeywordHistory.ts +++ b/frontend/src/composables/useKeywordHistory.ts @@ -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(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) } // 下拉选项:按当前输入过滤,历史非空时末尾追加「清空历史」 diff --git a/frontend/src/composables/usePreference.ts b/frontend/src/composables/usePreference.ts new file mode 100644 index 0000000..8bc38f0 --- /dev/null +++ b/frontend/src/composables/usePreference.ts @@ -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 { + const { legacyKeys = [], validate } = options + // 首次读取前迁移历史键,保证老用户偏好不丢失 + legacyKeys.forEach((legacy) => migrateKey(legacy, key)) + + const initial = readString(key, defaultValue) + const value = ref(validate && !validate(initial) ? defaultValue : initial) + + watch(value, (next) => writeString(key, next)) + return value +} diff --git a/frontend/src/locales/index.ts b/frontend/src/locales/index.ts index e22d2d0..f9146ae 100644 --- a/frontend/src/locales/index.ts +++ b/frontend/src/locales/index.ts @@ -1,6 +1,8 @@ // 国际化入口:由对照消息构建各语言包,维护当前语言、持久化与 同步 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,并同步 */ export function setLocale(locale: LocaleKey) { i18n.global.locale.value = locale - localStorage.setItem(STORAGE_KEY, locale) + writeString(STORAGE_KEY, locale) document.documentElement.lang = locale } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 7a219f3..b3f0027 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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'}` }, }, diff --git a/frontend/src/store/user.ts b/frontend/src/store/user.ts index 2cec3c3..119fcdc 100644 --- a/frontend/src/store/user.ts +++ b/frontend/src/store/user.ts @@ -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(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 } diff --git a/frontend/src/utils/storage.ts b/frontend/src/utils/storage.ts new file mode 100644 index 0000000..91735da --- /dev/null +++ b/frontend/src/utils/storage.ts @@ -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(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) { + // 忽略迁移失败,保持旧键可用 + } +} diff --git a/frontend/src/utils/storageKeys.ts b/frontend/src/utils/storageKeys.ts new file mode 100644 index 0000000..ae2e1d5 --- /dev/null +++ b/frontend/src/utils/storageKeys.ts @@ -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}` +} diff --git a/frontend/src/utils/transactionFilterPrefs.ts b/frontend/src/utils/transactionFilterPrefs.ts new file mode 100644 index 0000000..62212f0 --- /dev/null +++ b/frontend/src/utils/transactionFilterPrefs.ts @@ -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 { + const raw = readJson>(STORAGE_KEYS.transactionFilterPrefs, {}) + const result: Partial = {} + 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): void { + const merged = { ...loadTransactionFilterPrefs(), ...patch } + writeJson(STORAGE_KEYS.transactionFilterPrefs, merged) +} diff --git a/frontend/src/views/BillImportPanel.vue b/frontend/src/views/BillImportPanel.vue index ca13baa..5f90bd9 100644 --- a/frontend/src/views/BillImportPanel.vue +++ b/frontend/src/views/BillImportPanel.vue @@ -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([]) @@ -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(readBillTypePref()) +const billType = useStringPreference(BILL_TYPE_PREFS_KEY, '') // 各账单类型允许上传的文件扩展名(后端扩展名 + zip 压缩包) const allowedExtensions = computed>(() => { @@ -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) }) // 挂载时重置并加载账单类型(页面进入或弹窗打开时均会触发) diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index d33ea62..0bb97a2 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -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>(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" /> diff --git a/frontend/src/views/EmailBillManage.vue b/frontend/src/views/EmailBillManage.vue index ef74c98..0ec5eef 100644 --- a/frontend/src/views/EmailBillManage.vue +++ b/frontend/src/views/EmailBillManage.vue @@ -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((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}`) } diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue index c4c3bd0..bdf21a7 100644 --- a/frontend/src/views/Layout.vue +++ b/frontend/src/views/Layout.vue @@ -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 | 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 */ diff --git a/frontend/src/views/Logs.vue b/frontend/src/views/Logs.vue index f7a9d4b..e61ef03 100644 --- a/frontend/src/views/Logs.vue +++ b/frontend/src/views/Logs.vue @@ -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((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') { diff --git a/frontend/src/views/TransactionList.vue b/frontend/src/views/TransactionList.vue index 3996457..5343142 100644 --- a/frontend/src/views/TransactionList.vue +++ b/frontend/src/views/TransactionList.vue @@ -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([]) // 图片记账上传文件列表 @@ -291,7 +266,7 @@ const filterDateRange = ref<[number, number] | null>(null) // 日期范围筛选 const filterIncludeInStats = ref(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" />