From 0a8811534bf2eb5af64e8a2ca44182f0f73234a6 Mon Sep 17 00:00:00 2001 From: rosercode Date: Sun, 20 Sep 2026 15:09:35 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=89=8D=E7=AB=AF=20SSE=20?= =?UTF-8?q?=E9=89=B4=E6=9D=83=E6=94=B9=E9=80=A0=E4=B8=8E=E5=85=AC=E5=85=B1?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E6=8A=BD=E5=8F=96=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E5=88=86=E7=BB=84=E7=A9=BA=E6=8C=87=E9=92=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SSE 由 EventSource 改为 fetch 流式读取(新增 utils/sse.ts),token 走 Authorization 请求头,避免令牌经 URL 泄露到访问日志 - 抽取 useKeywordHistory,统一备注关键词与 AI 查询历史的读写、下拉与清空 - 抽取 renderCategoryLabel 至 utils/render.ts,消除多处重复实现 - 导出改用统一 request 实例(自动携带鉴权头),并将 blob 错误体解析为业务异常 - 新增 untagged 筛选参数;AI 智能查询改为弹窗入口并补充三语文案 - 分类/标签/邮箱/交易表单补充提交 loading,避免重复提交 - 交易时间改用本地格式化,修复 toISOString 造成的时区偏移 - 移除 Naive UI 全局注册;修正 NLog 的 rows 属性为 lines - 修复未分类记录分组时 categoryMap.get(null) 抛出的 NPE --- frontend/src/api/request.ts | 22 +- frontend/src/api/statistics.ts | 1 + frontend/src/api/transaction.ts | 10 +- frontend/src/components/BillPreviewPanel.vue | 34 +- frontend/src/composables/useKeywordHistory.ts | 105 ++++++ frontend/src/locales/messages.ts | 3 + frontend/src/main.ts | 2 - frontend/src/utils/render.ts | 10 + frontend/src/utils/sse.ts | 90 +++++ frontend/src/views/CategoryManage.vue | 6 +- frontend/src/views/Dashboard.vue | 11 +- frontend/src/views/EmailBillManage.vue | 115 +++--- frontend/src/views/Logs.vue | 71 ++-- frontend/src/views/RuleManage.vue | 9 +- frontend/src/views/TagManage.vue | 6 +- frontend/src/views/TransactionList.vue | 351 ++++++------------ .../service/impl/StatisticsServiceImpl.java | 5 +- 17 files changed, 476 insertions(+), 375 deletions(-) create mode 100644 frontend/src/composables/useKeywordHistory.ts create mode 100644 frontend/src/utils/render.ts create mode 100644 frontend/src/utils/sse.ts diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index 0c3a446..30bcfad 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -20,7 +20,27 @@ request.interceptors.request.use((config) => { // 响应拦截器:统一处理响应数据和错误 request.interceptors.response.use( - (response) => { + async (response) => { + // 二进制响应(文件下载)不走业务状态码校验; + // 后端出错时会返回 JSON 错误体,这里解析后按业务错误抛出,避免下载到损坏文件 + if (response.config.responseType === 'blob') { + const blob = response.data as Blob + if (blob?.type?.includes('application/json')) { + let bizMessage = '请求失败' + let bizCode: number | undefined + try { + const parsed = JSON.parse(await blob.text()) + bizMessage = parsed.message || bizMessage + bizCode = parsed.code + } catch { + // 解析失败时使用默认错误信息 + } + const bizError: any = new Error(bizMessage) + bizError.code = bizCode + return Promise.reject(bizError) + } + return response + } const data = response.data // 如果业务状态码不是 200,视为请求失败 if (data.code !== 200) { diff --git a/frontend/src/api/statistics.ts b/frontend/src/api/statistics.ts index f46722c..b0e90ee 100644 --- a/frontend/src/api/statistics.ts +++ b/frontend/src/api/statistics.ts @@ -23,6 +23,7 @@ export interface StatisticsFilterParams { types?: string[] categoryIds?: number[] uncategorized?: boolean + untagged?: boolean paymentMethods?: string[] minAmount?: number maxAmount?: number diff --git a/frontend/src/api/transaction.ts b/frontend/src/api/transaction.ts index 00c8191..c5e2335 100644 --- a/frontend/src/api/transaction.ts +++ b/frontend/src/api/transaction.ts @@ -1,7 +1,5 @@ // 收支记录相关 API 接口:交易记录的增删改查(分页) import request from './request' -import axios from 'axios' -import { useUserStore } from '../store/user' /** 获取系统支持的支付方式编码列表(含账单导入来源与人工支付方式) */ export function getPaymentMethods() { @@ -25,6 +23,7 @@ export function getTransactionPage(params: { sortField?: string sortOrder?: string tagIds?: number[] + untagged?: boolean }) { return request.get('/transaction/page', { params }) } @@ -174,16 +173,13 @@ export function exportTransaction(params: { sortField?: string sortOrder?: string tagIds?: number[] + untagged?: boolean }) { - const userStore = useUserStore() - return axios.get('/api/transaction/export', { + return request.get('/transaction/export', { params, paramsSerializer: { indexes: null, }, responseType: 'blob', - headers: { - Authorization: `Bearer ${userStore.token}`, - }, }) } diff --git a/frontend/src/components/BillPreviewPanel.vue b/frontend/src/components/BillPreviewPanel.vue index f178bd6..ea2013a 100644 --- a/frontend/src/components/BillPreviewPanel.vue +++ b/frontend/src/components/BillPreviewPanel.vue @@ -10,6 +10,7 @@ import type { DataTableColumns } from 'naive-ui' import type { ParsedBillRow, BillImportItem } from '../api/transaction' import { getCategoryList } from '../api/category' import { getTagList } from '../api/tag' +import { renderCategoryLabel } from '../utils/render' const props = defineProps<{ /** 解析结果行 */ @@ -34,6 +35,14 @@ const { t } = useI18n() // 勾选状态 const checkedRowNos = ref([]) +// 用户对「计入统计」的临时覆盖(按行号),避免直接修改父组件传入的 props.rows +const includeOverrides = ref>({}) + +/** 读取某行是否计入统计(优先取用户覆盖值) */ +function rowIncludeInStats(row: ParsedBillRow): boolean { + return includeOverrides.value[row.rowNo] ?? row.includeInStats +} + // 筛选条件 const filterKeyword = ref('') const filterType = ref(null) @@ -83,7 +92,7 @@ const filteredRows = computed(() => { if (!text.includes(kw)) return false } if (filterType.value === 'NEUTRAL') { - if (r.includeInStats) return false + if (rowIncludeInStats(r)) return false } else if (filterType.value && r.type !== filterType.value) { return false } @@ -143,7 +152,7 @@ const columns = computed>(() => [ if (!row.importable) { return h(NTag, { type: 'error', size: 'small' }, { default: () => t('import.notImportable') }) } - if (!row.includeInStats) { + if (!rowIncludeInStats(row)) { return h(NTag, { type: 'default', size: 'small' }, { default: () => t('import.notIncluded') }) } return h( @@ -160,7 +169,7 @@ const columns = computed>(() => [ align: 'center', render: (row) => h(NCheckbox, { - checked: !!row.includeInStats, + checked: rowIncludeInStats(row), disabled: !row.importable, onUpdateChecked: (v: boolean) => toggleIncludeInStats(row, v), }), @@ -236,14 +245,6 @@ async function loadCategories() { } } -/** 分类下拉项渲染:图标 + 名称 */ -function renderCategoryLabel(option: any) { - return h('span', null, [ - option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null, - option.label, - ]) -} - /** 加载标签选项 */ async function loadTags() { try { @@ -272,7 +273,7 @@ function toggleAll(checked: boolean) { /** 切换单行是否计入统计 */ function toggleIncludeInStats(row: ParsedBillRow, value: boolean) { - row.includeInStats = value + includeOverrides.value = { ...includeOverrides.value, [row.rowNo]: value } } /** 批量设置已勾选行的计入统计标记 */ @@ -281,9 +282,11 @@ function setCheckedIncludeInStats(value: boolean) { message.warning(t('import.selectAtLeastOne')) return } + const next = { ...includeOverrides.value } checkedRows.value.forEach((row) => { - row.includeInStats = value + next[row.rowNo] = value }) + includeOverrides.value = next } /** 确认导入:向父组件抛出勾选行与默认归类 */ @@ -299,7 +302,7 @@ function handleConfirm() { amount: r.amount, counterparty: r.counterparty, product: r.product, - includeInStats: r.includeInStats, + includeInStats: rowIncludeInStats(r), rawData: r.rawData, })) emit('confirm', { @@ -310,9 +313,10 @@ function handleConfirm() { }) } -// 解析结果变化时重置勾选(默认选中解析器建议行)与筛选条件 +// 解析结果变化时重置勾选(默认选中解析器建议行)、计入统计覆盖与筛选条件 watch(() => props.rows, (rows) => { checkedRowNos.value = rows.filter((r) => r.selected).map((r) => r.rowNo) + includeOverrides.value = {} resetFilters() }, { immediate: true }) diff --git a/frontend/src/composables/useKeywordHistory.ts b/frontend/src/composables/useKeywordHistory.ts new file mode 100644 index 0000000..d27741c --- /dev/null +++ b/frontend/src/composables/useKeywordHistory.ts @@ -0,0 +1,105 @@ +// 关键词历史 composable:统一管理输入框历史记录的读写、下拉过滤与「清空历史」项 +import { computed, h, ref } from 'vue' +import type { Ref } from 'vue' +import type { AutoCompleteOption } from 'naive-ui' +import { NIcon } from 'naive-ui' +import { CloseCircleOutline } from '@vicons/ionicons5' + +/** 关键词历史配置 */ +export interface KeywordHistoryConfig { + /** localStorage 存储键 */ + storageKey: string + /** 与输入框绑定的文本 ref,由调用方持有 */ + text: Ref + /** 「清空历史」下拉项文案(函数形式,保证语言切换后取到最新值) */ + clearLabel: () => string + /** 历史条目上限,默认 10 */ + max?: number +} + +/** + * 管理输入框的关键词历史:持久化、去重、下拉过滤与清空。 + * + * @param config 存储键、文本 ref、清空文案与上限 + */ +export function useKeywordHistory(config: KeywordHistoryConfig) { + const { storageKey, text, clearLabel, max = 10 } = config + // 清空项哨兵值:以存储键派生,确保与真实关键词不冲突 + const clearValue = `__clear_history__${storageKey}` + const history = ref([]) + + /** 从 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 = [] + } + } + + /** 记录当前关键词:去重(忽略大小写)、最新在前、上限截断 */ + function record() { + const value = text.value.trim() + 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 { + // 忽略写入失败 + } + } + + /** 清空历史 */ + function clear() { + history.value = [] + try { + localStorage.removeItem(storageKey) + } catch { + // 忽略删除失败 + } + } + + // 下拉选项:按当前输入过滤,历史非空时末尾追加「清空历史」 + const options = computed(() => { + const keyword = text.value.trim().toLowerCase() + const list: AutoCompleteOption[] = history.value + .filter((item) => item.toLowerCase().includes(keyword)) + .map((item) => ({ label: item, value: item })) + if (history.value.length > 0) { + list.push({ label: clearValue, value: clearValue }) + } + return list + }) + + /** 输入同步:命中清空项则清空历史,否则写回输入 */ + function handleInput(value: string | null) { + if (value === clearValue) { + clear() + return + } + text.value = value || '' + } + + /** 聚焦即展示历史下拉 */ + function showMenu() { + return true + } + + /** 渲染下拉项,清空项使用弱化样式与图标 */ + function renderLabel(option: AutoCompleteOption) { + if (String(option.value) === clearValue) { + return h('span', { style: { display: 'flex', alignItems: 'center', gap: '4px', color: '#999' } }, [ + h(NIcon, { size: 16 }, { default: () => h(CloseCircleOutline) }), + clearLabel(), + ]) + } + return option.label as string + } + + return { history, options, clearValue, load, record, clear, handleInput, showMenu, renderLabel } +} diff --git a/frontend/src/locales/messages.ts b/frontend/src/locales/messages.ts index 6f8711e..fa468cc 100644 --- a/frontend/src/locales/messages.ts +++ b/frontend/src/locales/messages.ts @@ -630,6 +630,9 @@ export default { placeholder: { zh: '用自然语言查询,如:上个月餐饮花了多少', en: 'Ask in natural language, e.g. dining expenses last month', ja: '自然言語で検索(例:先月の食費はいくら)' }, examples: { zh: '上个月餐饮|超过500的支出|待报销的支出|微信支付', en: 'Dining last month|Expenses over 500|Pending reimbursement|Paid by WeChat', ja: '先月の食費|500を超える支出|精算待ちの支出|WeChat 払い' }, button: { zh: 'AI 查询', en: 'AI Query', ja: 'AI 検索' }, + entry: { zh: 'AI 搜索', en: 'AI Search', ja: 'AI 検索' }, + title: { zh: 'AI 智能查询', en: 'AI Smart Query', ja: 'AI スマート検索' }, + hint: { zh: '用自然语言描述查询条件,AI 会解析为筛选条件并搜索。', en: 'Describe the query in natural language; AI will turn it into filters and search.', ja: '自然言語で検索条件を説明すると、AI がフィルターに変換して検索します。' }, emptyInput: { zh: '请输入查询内容', en: 'Please enter a query', ja: '検索内容を入力してください' }, clearHistory: { zh: '清空历史', en: 'Clear history', ja: '履歴をクリア' }, failed: { zh: 'AI 查询解析失败', en: 'Failed to parse the query', ja: 'AI 検索の解析に失敗しました' }, diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 909afff..c0765a0 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,7 +1,6 @@ // 应用入口文件:初始化 Vue 应用,挂载全局插件(状态管理、路由、UI 组件库) import { createApp } from 'vue' import { createPinia } from 'pinia' -import naive from 'naive-ui' import router from './router' import i18n from './locales' import App from './App.vue' @@ -10,6 +9,5 @@ import App from './App.vue' const app = createApp(App) app.use(createPinia()) // 注册 Pinia 状态管理 app.use(router) // 注册路由 -app.use(naive) // 全局注册 Naive UI 组件库 app.use(i18n) // 注册国际化 app.mount('#app') // 挂载到 DOM 元素 #app diff --git a/frontend/src/utils/render.ts b/frontend/src/utils/render.ts new file mode 100644 index 0000000..def5927 --- /dev/null +++ b/frontend/src/utils/render.ts @@ -0,0 +1,10 @@ +// 通用渲染工具:为下拉选项等场景提供统一的渲染函数 +import { h } from 'vue' + +/** 分类下拉项渲染:图标 + 名称(下拉选项与已选项均生效) */ +export function renderCategoryLabel(option: any) { + return h('span', null, [ + option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null, + option.label, + ]) +} diff --git a/frontend/src/utils/sse.ts b/frontend/src/utils/sse.ts new file mode 100644 index 0000000..28b5209 --- /dev/null +++ b/frontend/src/utils/sse.ts @@ -0,0 +1,90 @@ +// SSE 客户端工具:基于 fetch + ReadableStream 手动解析 Server-Sent Events +// EventSource 无法自定义请求头,只能把 token 放进 URL 查询参数,会泄露到访问日志; +// 这里改用 fetch 携带 Authorization 请求头,避免敏感信息出现在 URL 中。 +import { useUserStore } from '../store/user' + +/** SSE 事件回调集合 */ +export interface SseOptions { + /** 收到事件时回调,event 为事件名,data 为已合并多行的数据 */ + onEvent: (event: string, data: string) => void + /** 连接异常(含认证失败、网络错误)时回调 */ + onError?: (error: unknown) => void + /** 连接正常结束时回调 */ + onClose?: () => void +} + +/** + * 建立 SSE 连接。 + * + * @param url 相对或绝对地址(不含 token 查询参数) + * @param options 事件回调 + * @returns 用于中断连接的 AbortController + */ +export function openSse(url: string, options: SseOptions): AbortController { + const controller = new AbortController() + const userStore = useUserStore() + + fetch(url, { + headers: { + Authorization: `Bearer ${userStore.token}`, + Accept: 'text/event-stream', + }, + signal: controller.signal, + }) + .then(async (response) => { + if (!response.ok || !response.body) { + throw new Error(`SSE 连接失败:${response.status}`) + } + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + // 持续读取并按空行切分事件报文 + for (;;) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + let separator = buffer.indexOf('\n\n') + while (separator >= 0) { + dispatchEvent(buffer.slice(0, separator), options) + buffer = buffer.slice(separator + 2) + separator = buffer.indexOf('\n\n') + } + } + options.onClose?.() + }) + .catch((error: unknown) => { + // 主动中断不视为错误 + if ((error as Error)?.name === 'AbortError') return + options.onError?.(error) + }) + + return controller +} + +/** + * 解析单个 SSE 报文,合并多行 data 后回调。 + * + * @param chunk 不含结尾空行的单个事件报文 + * @param options 事件回调 + */ +function dispatchEvent(chunk: string, options: SseOptions) { + let event = 'message' + const dataLines: string[] = [] + for (const rawLine of chunk.split('\n')) { + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine + // 空行与以冒号开头的注释行(心跳)直接跳过 + if (!line || line.startsWith(':')) continue + const colon = line.indexOf(':') + const field = colon < 0 ? line : line.slice(0, colon) + let value = colon < 0 ? '' : line.slice(colon + 1) + if (value.startsWith(' ')) value = value.slice(1) + if (field === 'event') { + event = value + } else if (field === 'data') { + dataLines.push(value) + } + } + if (dataLines.length > 0) { + options.onEvent(event, dataLines.join('\n')) + } +} diff --git a/frontend/src/views/CategoryManage.vue b/frontend/src/views/CategoryManage.vue index 708cae3..23e6ec4 100644 --- a/frontend/src/views/CategoryManage.vue +++ b/frontend/src/views/CategoryManage.vue @@ -25,6 +25,7 @@ interface CategoryItem { // 列表和弹窗状态 const categories = ref([]) // 全部分类列表 const loading = ref(false) // 列表加载状态 +const submitting = ref(false) // 表单提交状态 const showModal = ref(false) // 新增/编辑弹窗显示状态 const editingId = ref(null) // 当前编辑的分类 ID(null 表示新增) const form = ref({ name: '', type: 'EXPENSE', icon: '', sortOrder: 0 }) // 表单数据 @@ -119,6 +120,7 @@ async function handleSubmit() { message.warning(t('category.nameRequired')) return } + submitting.value = true try { if (editingId.value) { // 更新已有分类 @@ -133,6 +135,8 @@ async function handleSubmit() { await fetchData() // 刷新列表 } catch (e: any) { message.error(e.message || t('common.operationFailed')) + } finally { + submitting.value = false } } @@ -260,7 +264,7 @@ onMounted(() => { - {{ t('common.confirm') }} + {{ t('common.confirm') }} diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 79e4e75..ae72f61 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -1,6 +1,6 @@