refactor: 前端 SSE 鉴权改造与公共逻辑抽取,修复统计分组空指针

- 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
This commit is contained in:
2026-09-20 15:09:35 +08:00
parent 5fb3d78ce0
commit 0a8811534b
17 changed files with 476 additions and 375 deletions
+21 -1
View File
@@ -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) {
+1
View File
@@ -23,6 +23,7 @@ export interface StatisticsFilterParams {
types?: string[]
categoryIds?: number[]
uncategorized?: boolean
untagged?: boolean
paymentMethods?: string[]
minAmount?: number
maxAmount?: number
+3 -7
View File
@@ -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}`,
},
})
}
+19 -15
View File
@@ -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<number[]>([])
// 用户对「计入统计」的临时覆盖(按行号),避免直接修改父组件传入的 props.rows
const includeOverrides = ref<Record<number, boolean>>({})
/** 读取某行是否计入统计(优先取用户覆盖值) */
function rowIncludeInStats(row: ParsedBillRow): boolean {
return includeOverrides.value[row.rowNo] ?? row.includeInStats
}
// 筛选条件
const filterKeyword = ref('')
const filterType = ref<string | null>(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<DataTableColumns<ParsedBillRow>>(() => [
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<DataTableColumns<ParsedBillRow>>(() => [
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 })
@@ -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<string>
/** 「清空历史」下拉项文案(函数形式,保证语言切换后取到最新值) */
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<string[]>([])
/** 从 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<AutoCompleteOption[]>(() => {
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 }
}
+3
View File
@@ -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 検索の解析に失敗しました' },
-2
View File
@@ -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
+10
View File
@@ -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,
])
}
+90
View File
@@ -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'))
}
}
+5 -1
View File
@@ -25,6 +25,7 @@ interface CategoryItem {
// 列表和弹窗状态
const categories = ref<CategoryItem[]>([]) // 全部分类列表
const loading = ref(false) // 列表加载状态
const submitting = ref(false) // 表单提交状态
const showModal = ref(false) // 新增/编辑弹窗显示状态
const editingId = ref<number | null>(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(() => {
<NFormItem :label="t('category.sort')">
<NInputNumber v-model:value="form.sortOrder" :min="0" />
</NFormItem>
<NButton type="primary" block @click="handleSubmit">{{ t('common.confirm') }}</NButton>
<NButton type="primary" block :loading="submitting" @click="handleSubmit">{{ t('common.confirm') }}</NButton>
</NForm>
</NModal>
</div>
+2 -9
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 统计面板组件:展示指定时间范围的收支汇总、概览指标、趋势、分类占比与支付方式分布
import { ref, computed, onMounted, watch, nextTick, h } from 'vue'
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect, NDatePicker, NInput, NInputNumber, NButton } from 'naive-ui'
import VChart from 'vue-echarts'
@@ -12,6 +12,7 @@ import { getStatisticsSummary, getStatisticsGroup } from '../api/statistics'
import { getCategoryList } from '../api/category'
import { getPaymentMethods } from '../api/transaction'
import { getPaymentLabel } from '../utils/payment'
import { renderCategoryLabel } from '../utils/render'
// 注册 ECharts 所需组件
use([CanvasRenderer, LineChart, PieChart, BarChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
@@ -81,14 +82,6 @@ const categoryFilterOptions = computed(() => [
...categories.value.filter(c => filterType.value.length === 0 || filterType.value.includes(c.type)),
])
/** 分类下拉项渲染:图标 + 名称 */
function renderCategoryLabel(option: any) {
return h('span', null, [
option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null,
option.label,
])
}
/** 将分类筛选拆分为分类ID与未分类标记 */
function buildCategoryFilter() {
const ids = filterCategory.value.filter((v) => v !== 0)
+57 -58
View File
@@ -5,7 +5,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
useMessage, useDialog, NSpace, NButton, NDataTable, NTag, NModal, NForm, NFormItem,
NInput, NInputNumber, NSwitch, NSelect, NAlert, NText, NCard, NTabs, NTabPane,
NInput, NInputNumber, NSwitch, NSelect, NAlert, NText, NCard, NTabs, NTabPane, NLog,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import {
@@ -23,16 +23,16 @@ import type {
import type { ParsedBillRow, BillType, BillImportItem } from '../api/transaction'
import { getCategoryList } from '../api/category'
import { getTagList } from '../api/tag'
import { renderCategoryLabel } from '../utils/render'
import BillPreviewPanel from '../components/BillPreviewPanel.vue'
import BillImportResult from '../components/BillImportResult.vue'
import { useUserStore } from '../store/user'
import { openSse } from '../utils/sse'
const message = useMessage()
const dialog = useDialog()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
/** 待处理列表自动刷新间隔(毫秒) */
const MESSAGE_REFRESH_MS = 30000
@@ -77,14 +77,6 @@ const expenseCategories = ref<{ label: string; value: number; icon: string }[]>(
const incomeCategories = ref<{ label: string; value: number; icon: string }[]>([])
const allTags = ref<{ label: string; value: number }[]>([])
/** 分类下拉项渲染:图标 + 名称 */
function renderCategoryLabel(option: any) {
return h('span', null, [
option.icon ? h('span', { style: 'margin-right:6px' }, option.icon) : null,
option.label,
])
}
async function loadOptions() {
try {
const [expenseRes, incomeRes, tagRes]: any = await Promise.all([
@@ -104,11 +96,12 @@ const configLoading = ref(false)
const configModalVisible = ref(false)
const configEditingId = ref<number | null>(null)
const configForm = ref<EmailBillConfigPayload>(emptyConfigForm())
const configSaving = ref(false) // 邮箱配置保存状态
// ===== 立即检查实时进度 =====
const checkLogs = ref<string[]>([])
const checkRunning = ref(false)
let checkSource: EventSource | null = null
let checkController: AbortController | null = null
let checkFinished = false
function emptyConfigForm(): EmailBillConfigPayload {
@@ -206,6 +199,7 @@ function openConfigModal(row?: EmailBillConfig) {
}
async function saveConfig() {
configSaving.value = true
try {
if (configEditingId.value) {
await updateEmailBillConfig(configEditingId.value, configForm.value)
@@ -217,6 +211,8 @@ async function saveConfig() {
await loadConfigs()
} catch (e: any) {
message.error(e.message || t('emailBill.common.loadFailed'))
} finally {
configSaving.value = false
}
}
@@ -264,46 +260,45 @@ function handleCheckConfig(row: EmailBillConfig) {
checkRunning.value = true
checkFinished = false
const params = new URLSearchParams()
// EventSource 无法自定义请求头,token 通过查询参数传递
params.set('token', userStore.token || '')
const source = new EventSource(`/api/email-bill/configs/${row.id}/check/stream?${params.toString()}`)
checkSource = source
source.addEventListener('line', (e: MessageEvent) => {
checkLogs.value.push(e.data)
checkController = openSse(`/api/email-bill/configs/${row.id}/check/stream`, {
onEvent: async (event, data) => {
if (event === 'line') {
checkLogs.value.push(data)
} else if (event === 'done') {
let count = 0
try {
count = JSON.parse(data).count ?? 0
} catch {
count = 0
}
checkLogs.value.push(t('emailBill.config.checkResult', { count }))
finishCheck()
await Promise.all([loadConfigs(), loadMessages()])
} else if (event === 'failed') {
let reason = ''
try {
reason = JSON.parse(data).message ?? ''
} catch {
reason = ''
}
checkLogs.value.push(reason ? `${t('emailBill.config.checkFailed')}:${reason}` : t('emailBill.config.checkFailed'))
finishCheck()
loadConfigs()
}
},
onError: () => {
// 连接异常(含认证失败)时结束检查
if (!checkFinished) {
finishCheck()
}
},
onClose: () => {
// 服务端正常结束但未收到 done/failed 时兜底收尾
if (!checkFinished) {
finishCheck()
}
},
})
source.addEventListener('done', async (e: MessageEvent) => {
let count = 0
try {
count = JSON.parse(e.data).count ?? 0
} catch {
count = 0
}
checkLogs.value.push(t('emailBill.config.checkResult', { count }))
finishCheck()
await Promise.all([loadConfigs(), loadMessages()])
})
source.addEventListener('failed', (e: MessageEvent) => {
let reason = ''
try {
reason = JSON.parse(e.data).message ?? ''
} catch {
reason = ''
}
checkLogs.value.push(reason ? `${t('emailBill.config.checkFailed')}:${reason}` : t('emailBill.config.checkFailed'))
finishCheck()
loadConfigs()
})
source.onerror = () => {
// 连接异常或服务端结束后关闭,避免 EventSource 自动重连导致重复检查
if (!checkFinished) {
finishCheck()
}
}
}
/** 结束检查状态并关闭 SSE 连接 */
@@ -315,9 +310,9 @@ function finishCheck() {
/** 关闭当前 SSE 连接 */
function stopCheckStream() {
if (checkSource) {
checkSource.close()
checkSource = null
if (checkController) {
checkController.abort()
checkController = null
}
}
@@ -326,7 +321,8 @@ const senderRules = ref<EmailBillSenderRule[]>([])
const ruleLoading = ref(false)
const ruleModalVisible = ref(false)
const ruleEditingId = ref<number | null>(null)
const ruleForm = ref<EmailBillSenderRulePayload & { patternsText: string }>(emptyRuleForm())
const ruleForm = ref<Omit<EmailBillSenderRulePayload, 'senderPatterns'> & { patternsText: string }>(emptyRuleForm())
const ruleSaving = ref(false) // 发件人规则保存状态
function emptyRuleForm() {
return {
@@ -410,6 +406,7 @@ async function saveRule() {
incomeCategoryId: ruleForm.value.incomeCategoryId,
tagIds: ruleForm.value.tagIds,
}
ruleSaving.value = true
try {
if (ruleEditingId.value) {
await updateEmailBillSenderRule(ruleEditingId.value, payload)
@@ -421,6 +418,8 @@ async function saveRule() {
await loadSenderRules()
} catch (e: any) {
message.error(e.message)
} finally {
ruleSaving.value = false
}
}
@@ -682,7 +681,7 @@ onUnmounted(() => {
<NButton v-else size="tiny" @click="checkLogs = []">{{ t('emailBill.config.checkClear') }}</NButton>
</NSpace>
</template>
<NLog :rows="checkLogs" :loading="checkRunning" style="max-height: 240px;" />
<NLog :lines="checkLogs" :loading="checkRunning" style="max-height: 240px;" />
</NCard>
</NSpace>
</NTabPane>
@@ -801,7 +800,7 @@ onUnmounted(() => {
<template #footer>
<NSpace justify="end">
<NButton @click="configModalVisible = false">{{ t('emailBill.common.cancel') }}</NButton>
<NButton type="primary" @click="saveConfig">{{ t('emailBill.common.save') }}</NButton>
<NButton type="primary" :loading="configSaving" @click="saveConfig">{{ t('emailBill.common.save') }}</NButton>
</NSpace>
</template>
</NModal>
@@ -840,7 +839,7 @@ onUnmounted(() => {
<template #footer>
<NSpace justify="end">
<NButton @click="ruleModalVisible = false">{{ t('emailBill.common.cancel') }}</NButton>
<NButton type="primary" @click="saveRule">{{ t('emailBill.common.save') }}</NButton>
<NButton type="primary" :loading="ruleSaving" @click="saveRule">{{ t('emailBill.common.save') }}</NButton>
</NSpace>
</template>
</NModal>
+32 -39
View File
@@ -11,11 +11,10 @@ import type { DataTableColumns } from 'naive-ui'
import { RefreshOutline, SearchOutline } from '@vicons/ionicons5'
import { pageHttpLogs, pageLoginLogs, getAppLog, getLogRetention, updateLogRetention } from '../api/log'
import type { HttpLog, LoginLog, AppLog } from '../api/log'
import { useUserStore } from '../store/user'
import { openSse } from '../utils/sse'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const message = useMessage()
const { t } = useI18n()
@@ -66,8 +65,8 @@ const appLines = ref<number | null>(500)
const appRange = ref(3600000)
// 实时获取:开启后通过 SSE 订阅日志新增内容
const appRealtime = ref(false)
// SSE 连接
let eventSource: EventSource | null = null
// SSE 连接控制器
let sseController: AbortController | null = null
/** 实时模式下缓冲的最大行数 */
const MAX_REALTIME_LINES = 2000
@@ -253,9 +252,9 @@ async function loadApp() {
/** 关闭 SSE 实时连接 */
function stopRealtime() {
if (eventSource) {
eventSource.close()
eventSource = null
if (sseController) {
sseController.abort()
sseController = null
}
}
@@ -264,8 +263,6 @@ function startRealtime() {
stopRealtime()
const params = new URLSearchParams()
// EventSource 无法自定义请求头,token 通过查询参数传递
params.set('token', userStore.token || '')
if (appLevel.value) {
params.set('level', appLevel.value)
}
@@ -278,41 +275,37 @@ function startRealtime() {
params.set('since', String(since))
}
const source = new EventSource(`/api/log/app/stream?${params.toString()}`)
eventSource = source
// 元信息事件:初始化文件名与初始命中行数
source.addEventListener('meta', (e: MessageEvent) => {
try {
const meta = JSON.parse(e.data)
appData.value = { file: meta.file || '', total: meta.total || 0, lines: [] }
} catch {
appData.value = { file: '', total: 0, lines: [] }
}
})
// 日志行事件:按时间正序推送,插入列表头部实现最新在前
source.addEventListener('line', (e: MessageEvent) => {
if (!appData.value) {
appData.value = { file: '', total: 0, lines: [] }
}
appData.value.lines.unshift(e.data)
appData.value.total++
if (appData.value.lines.length > MAX_REALTIME_LINES) {
appData.value.lines.length = MAX_REALTIME_LINES
}
})
source.onerror = () => {
// 服务端关闭连接(如认证失败)时停止实时并提示,避免无限重连
if (source.readyState === EventSource.CLOSED) {
sseController = openSse(`/api/log/app/stream?${params.toString()}`, {
// 元信息事件:初始化文件名与初始命中行数
onEvent: (event, data) => {
if (event === 'meta') {
try {
const meta = JSON.parse(data)
appData.value = { file: meta.file || '', total: meta.total || 0, lines: [] }
} catch {
appData.value = { file: '', total: 0, lines: [] }
}
} else if (event === 'line') {
// 日志行事件:按时间正序推送,插入列表头部实现最新在前
if (!appData.value) {
appData.value = { file: '', total: 0, lines: [] }
}
appData.value.lines.unshift(data)
appData.value.total++
if (appData.value.lines.length > MAX_REALTIME_LINES) {
appData.value.lines.length = MAX_REALTIME_LINES
}
}
},
onError: () => {
// 认证失败或连接异常时停止实时并提示,避免无限重连
stopRealtime()
if (appRealtime.value) {
appRealtime.value = false
message.error(t('log.loadFailed'))
}
}
}
},
})
}
// 实时开关变化时建立/关闭 SSE 连接(仅在运行日志标签页生效)
+1 -8
View File
@@ -11,6 +11,7 @@ import { getRuleList, createRule, updateRule, deleteRule, toggleRuleEnabled } fr
import type { RuleItem } from '../api/rule'
import { getCategoryList } from '../api/category'
import { getTagList } from '../api/tag'
import { renderCategoryLabel } from '../utils/render'
const message = useMessage() // 消息提示
const dialog = useDialog() // 对话框
@@ -79,14 +80,6 @@ const categoryOptions = computed(() => {
// 标签选项
const tagOptions = computed(() => tags.value.map((tag) => ({ label: tag.label, value: tag.value })))
/** 分类下拉项渲染:图标 + 名称 */
function renderCategoryLabel(option: any) {
return h('span', null, [
option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null,
option.label,
])
}
/** 构造条件摘要标签,便于列表快速识别规则 */
function conditionTags(row: RuleItem) {
const result: any[] = []
+5 -1
View File
@@ -23,6 +23,7 @@ interface TagItem {
// 列表和弹窗状态
const tags = ref<TagItem[]>([]) // 标签列表
const loading = ref(false) // 列表加载状态
const submitting = ref(false) // 表单提交状态
const showModal = ref(false) // 新增/编辑弹窗显示状态
const editingId = ref<number | null>(null) // 当前编辑的标签 ID(null 表示新增)
const form = ref<{ name: string; color: string; sortOrder: number }>({ name: '', color: '#18a058', sortOrder: 0 }) // 表单数据
@@ -110,6 +111,7 @@ async function handleSubmit() {
message.warning(t('tag.nameRequired'))
return
}
submitting.value = true
try {
if (editingId.value) {
await updateTag(editingId.value, form.value)
@@ -122,6 +124,8 @@ async function handleSubmit() {
await fetchData()
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
submitting.value = false
}
}
@@ -205,7 +209,7 @@ onMounted(() => {
<NFormItem :label="t('tag.sort')">
<NInputNumber v-model:value="form.sortOrder" :min="0" />
</NFormItem>
<NButton type="primary" block @click="handleSubmit">{{ t('common.confirm') }}</NButton>
<NButton type="primary" block :loading="submitting" @click="handleSubmit">{{ t('common.confirm') }}</NButton>
</NForm>
</NModal>
</div>
+118 -233
View File
@@ -3,14 +3,16 @@
import { ref, onMounted, computed, h, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDivider, NDropdown } from 'naive-ui'
import { EyeOutline, EyeOffOutline, CloseCircleOutline, SparklesOutline, BulbOutline } from '@vicons/ionicons5'
import type { DataTableColumns, AutoCompleteOption } from 'naive-ui'
import { EyeOutline, EyeOffOutline, SparklesOutline, BulbOutline } from '@vicons/ionicons5'
import type { DataTableColumns } from 'naive-ui'
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags, getPaymentMethods, batchDeleteTransactions } from '../api/transaction'
import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi } from '../api/ai'
import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai'
import { getCategoryList, createCategory } from '../api/category'
import { getTagList, createTag } from '../api/tag'
import { getPaymentLabel } from '../utils/payment'
import { renderCategoryLabel } from '../utils/render'
import { useKeywordHistory } from '../composables/useKeywordHistory'
import BillImportModal from './BillImportModal.vue'
const message = useMessage() // 消息提示
@@ -70,9 +72,9 @@ const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.v
const pageSummary = computed(() => {
let income = 0
let expense = 0
for (const t of transactions.value) {
const amount = Number(t.amount) || 0
if (t.type === 'INCOME') income += amount
for (const tx of transactions.value) {
const amount = Number(tx.amount) || 0
if (tx.type === 'INCOME') income += amount
else expense += amount
}
return { income, expense, balance: income - expense }
@@ -81,15 +83,16 @@ const pageSummary = computed(() => {
const pageExcludedSummary = computed(() => {
let income = 0
let expense = 0
for (const t of transactions.value) {
if (t.includeInStats) continue
const amount = Number(t.amount) || 0
if (t.type === 'INCOME') income += amount
for (const tx of transactions.value) {
if (tx.includeInStats) continue
const amount = Number(tx.amount) || 0
if (tx.type === 'INCOME') income += amount
else expense += amount
}
return { income, expense }
})
const loading = ref(false) // 列表加载状态
const submitting = ref(false) // 表单提交状态
const showModal = ref(false) // 新增/编辑弹窗显示状态
const showImportModal = ref(false) // 账单导入弹窗显示状态
const showBatchModal = ref(false) // 批量设置分类弹窗显示状态
@@ -130,7 +133,8 @@ const aiNewLoading = ref(false) // 编辑弹窗内 AI 生成新分类状态
const aiNewSaving = ref(false) // 新分类保存状态
// AI 生成但尚未保存的新分类,需用户确认后再落库
const pendingCategory = ref<{ name: string; type: string; icon: string } | null>(null)
const aiTagLoading = ref(false) // 编辑弹窗内标签 AI 请求状态
const aiTagSuggestLoading = ref(false) // 编辑弹窗内「推荐已有标签」请求状态
const aiTagNewLoading = ref(false) // 编辑弹窗内「生成新标签」请求状态
const aiTagSaving = ref(false) // 新标签保存状态
// AI 生成但尚未保存的新标签,需用户确认后再落库
const pendingTag = ref<{ name: string; color: string } | null>(null)
@@ -140,12 +144,15 @@ const checkedAiRowKeys = ref<number[]>([]) // AI 建议弹窗中已勾选的代
const aiQueryText = ref('') // AI 自然语言查询输入
const aiQuerying = ref(false) // AI 查询解析状态
const aiQueryResult = ref<AiQueryResult | null>(null) // AI 查询解析结果
// AI 查询历史存储键与上限
const showAiQueryModal = ref(false) // AI 智能查询弹窗显示状态
// AI 查询历史存储键
const AI_QUERY_HISTORY_KEY = 'finance.transaction.aiQueryHistory'
const AI_QUERY_HISTORY_MAX = 10
// 下拉中“清空历史”选项的哨兵值
const CLEAR_AI_QUERY_HISTORY = '__clear_ai_query_history__'
const aiQueryHistory = ref<string[]>([]) // AI 查询历史(最近使用,最新在前)
// AI 查询历史(最近使用,最新在前)
const aiQueryHistory = useKeywordHistory({
storageKey: AI_QUERY_HISTORY_KEY,
text: aiQueryText,
clearLabel: () => t('ai.query.clearHistory'),
})
const showAiRecordModal = ref(false) // AI 记账弹窗显示状态
const aiRecordText = ref('') // AI 记账输入
const aiRecording = ref(false) // AI 记账解析状态
@@ -158,8 +165,8 @@ const batchAddTagIds = ref<number[]>([]) // 批量添加的标签
const batchRemoveTagIds = ref<number[]>([]) // 批量移除的标签
const batching = ref(false) // 批量设置提交状态
// 当前表格是否包含收入/支出记录,用于按需展示批量分类项
const hasIncomeRecords = computed(() => transactions.value.some((t) => t.type === 'INCOME'))
const hasExpenseRecords = computed(() => transactions.value.some((t) => t.type === 'EXPENSE'))
const hasIncomeRecords = computed(() => transactions.value.some((tx) => tx.type === 'INCOME'))
const hasExpenseRecords = computed(() => transactions.value.some((tx) => tx.type === 'EXPENSE'))
// 批量设置计入统计选项
const batchStatsOptions = computed(() => [
@@ -206,12 +213,14 @@ const filterPaymentMethod = ref<string[]>([]) // 支付方式筛选(多选)
const filterDateRange = ref<[number, number] | null>(null) // 日期范围筛选(起止时间戳)
const filterIncludeInStats = ref<string | null>(null) // 计入统计筛选:null-全部,'true'-计入,'false'-不计入
const filterRemark = ref('') // 备注关键字搜索
// 备注关键词历史存储键与上限
// 备注关键词历史存储键
const REMARK_HISTORY_KEY = 'finance.transaction.remarkHistory'
const REMARK_HISTORY_MAX = 10
// 下拉中“清空历史”选项的哨兵值
const CLEAR_REMARK_HISTORY = '__clear_remark_history__'
const remarkHistory = ref<string[]>([]) // 备注关键词历史(最近输入,最新在前)
// 备注关键词历史(最近输入,最新在前)
const remarkHistory = useKeywordHistory({
storageKey: REMARK_HISTORY_KEY,
text: filterRemark,
clearLabel: () => t('transaction.remarkHistoryClear'),
})
const filterMinAmount = ref<number | null>(null) // 最小金额筛选
const filterMaxAmount = ref<number | null>(null) // 最大金额筛选
const filterTag = ref<number[]>([]) // 标签筛选(多选)
@@ -241,7 +250,7 @@ const categoryFilterOptions = computed(() => [
])
// 标签筛选项
const tagFilterOptions = computed(() => allTags.value.map((t) => ({ label: t.name, value: t.id })))
const tagFilterOptions = computed(() => allTags.value.map((tag) => ({ label: tag.name, value: tag.id })))
// 标签筛选下拉项:额外提供「无标签」选项(仅筛选使用,编辑/批量选择不包含)
const tagFilterSelectOptions = computed(() => [
@@ -326,67 +335,10 @@ watch(
{ deep: true },
)
/** 从 localStorage 读取备注关键词历史,忽略损坏数据 */
function loadRemarkHistory() {
try {
const raw = localStorage.getItem(REMARK_HISTORY_KEY)
const parsed = raw ? JSON.parse(raw) : []
remarkHistory.value = Array.isArray(parsed)
? parsed.filter((item) => typeof item === 'string' && item.trim()).slice(0, REMARK_HISTORY_MAX)
: []
} catch (e) {
remarkHistory.value = []
}
}
/** 记录一次备注关键词:去重(忽略大小写)、最新在前、上限 10 条 */
function recordRemarkKeyword() {
const value = filterRemark.value.trim()
if (!value) return
const rest = remarkHistory.value.filter((item) => item.toLowerCase() !== value.toLowerCase())
remarkHistory.value = [value, ...rest].slice(0, REMARK_HISTORY_MAX)
try {
localStorage.setItem(REMARK_HISTORY_KEY, JSON.stringify(remarkHistory.value))
} catch (e) {
// 忽略写入失败
}
}
/** 清空备注关键词历史 */
function clearRemarkHistory() {
remarkHistory.value = []
try {
localStorage.removeItem(REMARK_HISTORY_KEY)
} catch (e) {
// 忽略删除失败
}
}
// 备注历史下拉选项:按当前输入过滤历史项(忽略大小写),历史非空时末尾追加“清空历史”
const remarkOptions = computed<AutoCompleteOption[]>(() => {
const keyword = filterRemark.value.trim().toLowerCase()
const options: AutoCompleteOption[] = remarkHistory.value
.filter((item) => item.toLowerCase().includes(keyword))
.map((item) => ({ label: item, value: item }))
if (remarkHistory.value.length > 0) {
options.push({ label: CLEAR_REMARK_HISTORY, value: CLEAR_REMARK_HISTORY })
}
return options
})
/** 输入同步:清空项执行清空,其余写入输入框(NAutoComplete 回调传入的是选项 label) */
function handleRemarkInput(value: string | null) {
if (value === CLEAR_REMARK_HISTORY) {
clearRemarkHistory()
return
}
filterRemark.value = value || ''
}
/** 选中历史项:填入并立即搜索 */
function handleRemarkSelect(value: string) {
if (value === CLEAR_REMARK_HISTORY) {
clearRemarkHistory()
if (value === remarkHistory.clearValue) {
remarkHistory.clear()
return
}
filterRemark.value = value
@@ -395,34 +347,18 @@ function handleRemarkSelect(value: string) {
/** 失焦时记录当前备注关键词 */
function handleRemarkBlur() {
recordRemarkKeyword()
remarkHistory.record()
}
/** 回车时记录关键词并搜索(阻止 NAutoComplete 内部选中建议项,保持原回车搜索语义) */
function handleRemarkKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.stopPropagation()
recordRemarkKeyword()
remarkHistory.record()
handleSearch()
}
}
/** 聚焦即展示历史下拉(NAutoComplete 默认仅在输入非空时展示) */
function showRemarkMenu() {
return true
}
/** 渲染下拉选项,清空项使用弱化样式与图标 */
function renderRemarkLabel(option: AutoCompleteOption) {
if (String(option.value) === CLEAR_REMARK_HISTORY) {
return h('span', { style: { display: 'flex', alignItems: 'center', gap: '4px', color: '#999' } }, [
h(NIcon, { size: 16 }, { default: () => h(CloseCircleOutline) }),
t('transaction.remarkHistoryClear'),
])
}
return option.label as string
}
/** 将时间戳格式化为 ISO 格式的日期时间字符串(YYYY-MM-DDTHH:mm:ss) */
function formatTimestamp(ts: number, isEnd = false): string {
const d = new Date(ts)
@@ -432,12 +368,11 @@ function formatTimestamp(ts: number, isEnd = false): string {
return `${date}T${time}`
}
/** 分类下拉项渲染:图标 + 名称(下拉选项与已选项均生效) */
function renderCategoryLabel(option: any) {
return h('span', null, [
option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null,
option.label,
])
/** 将时间戳格式化为本地日期时间字符串(YYYY-MM-DDTHH:mm:ss),避免 UTC 转换导致时区偏移 */
function formatLocalDateTime(ts: number): string {
const d = new Date(ts)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
/** 分类已选项(多选标签)渲染:图标 + 名称 */
@@ -689,7 +624,7 @@ function handleEdit(row: TransactionItem) {
remark: row.remark || '',
transactionTime: new Date(row.transactionTime).getTime(),
includeInStats: row.includeInStats ?? true,
tagIds: (row.tags || []).map((t) => t.id),
tagIds: (row.tags || []).map((tag) => tag.id),
}
pendingCategory.value = null
pendingTag.value = null
@@ -713,10 +648,11 @@ async function handleSubmit() {
amount: form.value.amount!,
paymentMethod: form.value.paymentMethod || undefined,
remark: form.value.remark || undefined,
transactionTime: new Date(form.value.transactionTime).toISOString().slice(0, 19),
transactionTime: formatLocalDateTime(form.value.transactionTime),
includeInStats: form.value.includeInStats,
tagIds: form.value.tagIds,
}
submitting.value = true
try {
if (editingId.value) {
await updateTransaction(editingId.value, data)
@@ -729,6 +665,8 @@ async function handleSubmit() {
await fetchData()
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
submitting.value = false
}
}
@@ -840,7 +778,7 @@ async function handleAiSuggest() {
}
aiLoading.value = true
try {
const ids = transactions.value.map((t) => t.id)
const ids = transactions.value.map((tx) => tx.id)
const res: any = await suggestByAi(ids, aiScope.value)
aiSuggestions.value = res.data || []
if (aiSuggestions.value.length === 0) {
@@ -951,7 +889,7 @@ async function handleSaveNewCategory() {
/** 编辑弹窗内 AI 推荐已有标签:基于表单当前内容,追加到已选标签 */
async function handleAiTagSuggest() {
aiTagLoading.value = true
aiTagSuggestLoading.value = true
try {
const res: any = await suggestOneByAi({
type: form.value.type,
@@ -970,7 +908,7 @@ async function handleAiTagSuggest() {
} catch (e: any) {
message.error(e.message || t('ai.suggestFailed'))
} finally {
aiTagLoading.value = false
aiTagSuggestLoading.value = false
}
}
@@ -980,7 +918,7 @@ async function handleAiNewTag() {
message.warning(t('ai.newTag.needInfo'))
return
}
aiTagLoading.value = true
aiTagNewLoading.value = true
try {
const res: any = await parseTagByAi(buildCategoryPrompt())
const data = res.data
@@ -992,7 +930,7 @@ async function handleAiNewTag() {
} catch (e: any) {
message.error(e.message || t('ai.newTag.failed'))
} finally {
aiTagLoading.value = false
aiTagNewLoading.value = false
}
}
@@ -1135,6 +1073,15 @@ async function handleAiRecordParse() {
}
}
/** 打开 AI 智能查询弹窗 */
function handleAiQueryOpen() {
if (!aiEnabled.value) {
message.warning(t('ai.needConfig'))
return
}
showAiQueryModal.value = true
}
/** 请求 AI 解析自然语言查询 */
async function handleAiQuery() {
if (!aiEnabled.value) {
@@ -1146,7 +1093,7 @@ async function handleAiQuery() {
message.warning(t('ai.query.emptyInput'))
return
}
recordAiQueryHistory()
aiQueryHistory.record()
aiQuerying.value = true
try {
const res: any = await queryByAi(text)
@@ -1158,6 +1105,7 @@ async function handleAiQuery() {
}
// 直接应用解析结果并搜索,无需二次确认
applyAiQuery()
showAiQueryModal.value = false
} catch (e: any) {
message.error(e.message || t('ai.query.failed'))
} finally {
@@ -1214,67 +1162,10 @@ function handleExampleSelect(key: string) {
aiQueryText.value = key
}
/** 从 localStorage 读取 AI 查询历史,忽略损坏数据 */
function loadAiQueryHistory() {
try {
const raw = localStorage.getItem(AI_QUERY_HISTORY_KEY)
const parsed = raw ? JSON.parse(raw) : []
aiQueryHistory.value = Array.isArray(parsed)
? parsed.filter((item) => typeof item === 'string' && item.trim()).slice(0, AI_QUERY_HISTORY_MAX)
: []
} catch (e) {
aiQueryHistory.value = []
}
}
/** 记录一次 AI 查询文本:去重(忽略大小写)、最新在前、上限 10 条 */
function recordAiQueryHistory() {
const value = aiQueryText.value.trim()
if (!value) return
const rest = aiQueryHistory.value.filter((item) => item.toLowerCase() !== value.toLowerCase())
aiQueryHistory.value = [value, ...rest].slice(0, AI_QUERY_HISTORY_MAX)
try {
localStorage.setItem(AI_QUERY_HISTORY_KEY, JSON.stringify(aiQueryHistory.value))
} catch (e) {
// 忽略写入失败
}
}
/** 清空 AI 查询历史 */
function clearAiQueryHistory() {
aiQueryHistory.value = []
try {
localStorage.removeItem(AI_QUERY_HISTORY_KEY)
} catch (e) {
// 忽略删除失败
}
}
// AI 查询历史下拉选项:按当前输入过滤历史项(忽略大小写),历史非空时末尾追加“清空历史”
const aiQueryOptions = computed<AutoCompleteOption[]>(() => {
const keyword = aiQueryText.value.trim().toLowerCase()
const options: AutoCompleteOption[] = aiQueryHistory.value
.filter((item) => item.toLowerCase().includes(keyword))
.map((item) => ({ label: item, value: item }))
if (aiQueryHistory.value.length > 0) {
options.push({ label: CLEAR_AI_QUERY_HISTORY, value: CLEAR_AI_QUERY_HISTORY })
}
return options
})
/** 输入同步:清空项执行清空,其余写入输入框(NAutoComplete 回调传入的是选项 label) */
function handleAiQueryInput(value: string | null) {
if (value === CLEAR_AI_QUERY_HISTORY) {
clearAiQueryHistory()
return
}
aiQueryText.value = value || ''
}
/** 选中历史项:填入并立即发起查询 */
function handleAiQuerySelect(value: string) {
if (value === CLEAR_AI_QUERY_HISTORY) {
clearAiQueryHistory()
if (value === aiQueryHistory.clearValue) {
aiQueryHistory.clear()
return
}
aiQueryText.value = value
@@ -1289,22 +1180,6 @@ function handleAiQueryKeydown(e: KeyboardEvent) {
}
}
/** 聚焦即展示历史下拉 */
function showAiQueryMenu() {
return true
}
/** 渲染下拉选项,清空项使用弱化样式与图标 */
function renderAiQueryLabel(option: AutoCompleteOption) {
if (String(option.value) === CLEAR_AI_QUERY_HISTORY) {
return h('span', { style: { display: 'flex', alignItems: 'center', gap: '4px', color: '#999' } }, [
h(NIcon, { size: 16 }, { default: () => h(CloseCircleOutline) }),
t('ai.query.clearHistory'),
])
}
return option.label as string
}
/** 将时间戳格式化为 yyyy-MM-dd */
function formatDateLabel(ts: number) {
const d = new Date(ts)
@@ -1399,7 +1274,7 @@ function applyTagClear(tag: { clear: () => void }) {
const aiRows = computed(() =>
aiSuggestions.value.map((s) => ({
...s,
transaction: transactions.value.find((t) => t.id === s.transactionId) || null,
transaction: transactions.value.find((tx) => tx.id === s.transactionId) || null,
})),
)
@@ -1529,7 +1404,7 @@ async function handleBatchSubmit() {
}
batching.value = true
try {
const ids = transactions.value.map((t) => t.id)
const ids = transactions.value.map((tx) => tx.id)
if (hasCategoryOrStats) {
await batchUpdateCategory({
ids,
@@ -1607,8 +1482,8 @@ async function doExport() {
// 组件挂载时加载分类和交易记录
onMounted(() => {
loadFilterPrefs()
loadRemarkHistory()
loadAiQueryHistory()
remarkHistory.load()
aiQueryHistory.load()
loadAiConfig()
fetchCategories()
fetchTags()
@@ -1644,39 +1519,6 @@ onMounted(() => {
<!-- 查询与筛选 -->
<NCard :bordered="false" style="margin-bottom: 16px;">
<!-- AI 智能查询(仅启用时显示) -->
<template v-if="aiEnabled">
<NSpace align="center" :wrap="true" :size="[12, 8]">
<NAutoComplete
:value="aiQueryText"
:options="aiQueryOptions"
:get-show="showAiQueryMenu"
:render-label="renderAiQueryLabel"
:input-props="{ onKeydown: handleAiQueryKeydown }"
clearable
:placeholder="t('ai.query.placeholder')"
style="width: 360px;"
@update:value="handleAiQueryInput"
@select="handleAiQuerySelect"
>
<template #prefix>
<NIcon :component="SparklesOutline" />
</template>
</NAutoComplete>
<NButton type="primary" :loading="aiQuerying" @click="handleAiQuery">{{ t('ai.query.button') }}</NButton>
<NDropdown trigger="click" :options="exampleOptions" @select="handleExampleSelect">
<NButton text type="primary" size="small">
<template #icon>
<NIcon :component="BulbOutline" />
</template>
{{ t('transaction.exampleLabel') }}
</NButton>
</NDropdown>
</NSpace>
<NDivider style="margin: 12px 0;" />
</template>
<!-- 常用筛选条件 -->
<NSpace align="center" :wrap="true" :size="[12, 8]">
<NSelect
@@ -1708,14 +1550,14 @@ onMounted(() => {
/>
<NAutoComplete
:value="filterRemark"
:options="remarkOptions"
:get-show="showRemarkMenu"
:render-label="renderRemarkLabel"
:options="remarkHistory.options.value"
:get-show="remarkHistory.showMenu"
:render-label="remarkHistory.renderLabel"
:input-props="{ onKeydown: handleRemarkKeydown }"
clearable
:placeholder="t('transaction.column.remark')"
style="width: 180px;"
@update:value="handleRemarkInput"
@update:value="remarkHistory.handleInput"
@select="handleRemarkSelect"
@blur="handleRemarkBlur"
/>
@@ -1790,6 +1632,12 @@ onMounted(() => {
</NPopover>
<NButton type="primary" @click="handleSearch">{{ t('transaction.search') }}</NButton>
<NButton @click="handleReset">{{ t('transaction.reset') }}</NButton>
<NButton v-if="aiEnabled" @click="handleAiQueryOpen">
<template #icon>
<NIcon :component="SparklesOutline" />
</template>
{{ t('ai.query.entry') }}
</NButton>
</NSpace>
<!-- 已激活的筛选条件 -->
@@ -1893,8 +1741,8 @@ onMounted(() => {
:placeholder="t('transaction.tagPlaceholder')"
style="flex: 1;"
/>
<NButton v-if="aiEnabled" size="small" :loading="aiTagLoading" @click="handleAiTagSuggest">{{ t('ai.pickButton') }}</NButton>
<NButton v-if="aiEnabled" size="small" :loading="aiTagLoading" @click="handleAiNewTag">{{ t('ai.newTag.button') }}</NButton>
<NButton v-if="aiEnabled" size="small" :loading="aiTagSuggestLoading" @click="handleAiTagSuggest">{{ t('ai.pickButton') }}</NButton>
<NButton v-if="aiEnabled" size="small" :loading="aiTagNewLoading" @click="handleAiNewTag">{{ t('ai.newTag.button') }}</NButton>
</div>
</NFormItem>
<!-- AI 生成的新标签确认条:用户确认后才保存 -->
@@ -1908,7 +1756,7 @@ onMounted(() => {
<NSwitch v-model:value="form.includeInStats" />
<span style="color: #999; margin-left: 8px; font-size: 12px;">{{ t('transaction.includeInStatsHint') }}</span>
</NFormItem>
<NButton type="primary" block @click="handleSubmit">{{ t('common.confirm') }}</NButton>
<NButton type="primary" block :loading="submitting" @click="handleSubmit">{{ t('common.confirm') }}</NButton>
</NForm>
</NModal>
@@ -2005,6 +1853,43 @@ onMounted(() => {
</NSpace>
</NSpace>
</NModal>
<!-- AI 智能查询弹窗 -->
<NModal v-if="aiEnabled" v-model:show="showAiQueryModal" preset="card" :title="t('ai.query.title')" style="width: 520px; max-width: 92vw;">
<NSpace vertical :size="12">
<NAlert type="info" :show-icon="true">{{ t('ai.query.hint') }}</NAlert>
<NAutoComplete
:value="aiQueryText"
:options="aiQueryHistory.options.value"
:get-show="aiQueryHistory.showMenu"
:render-label="aiQueryHistory.renderLabel"
:input-props="{ onKeydown: handleAiQueryKeydown }"
clearable
:placeholder="t('ai.query.placeholder')"
style="width: 100%;"
@update:value="aiQueryHistory.handleInput"
@select="handleAiQuerySelect"
>
<template #prefix>
<NIcon :component="SparklesOutline" />
</template>
</NAutoComplete>
<NSpace justify="space-between" align="center">
<NDropdown trigger="click" :options="exampleOptions" @select="handleExampleSelect">
<NButton text type="primary" size="small">
<template #icon>
<NIcon :component="BulbOutline" />
</template>
{{ t('transaction.exampleLabel') }}
</NButton>
</NDropdown>
<NSpace>
<NButton @click="showAiQueryModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="aiQuerying" @click="handleAiQuery">{{ t('ai.query.button') }}</NButton>
</NSpace>
</NSpace>
</NSpace>
</NModal>
</div>
</template>
@@ -328,7 +328,10 @@ public class StatisticsServiceImpl implements StatisticsService {
Map<Long, Category> categoryMap, BigDecimal income, BigDecimal expense) {
switch (dimension) {
case CATEGORY -> {
Category category = categoryMap.get(parseLong(key));
// 未分类记录的分组键无法解析为分类 ID,此时 categoryMap 可能为不可变空 Map,
// 直接 get(null) 会抛 NPE,故先判空
Long categoryId = parseLong(key);
Category category = categoryId == null ? null : categoryMap.get(categoryId);
vo.setName(category != null ? category.getName() : "未分类");
vo.setIcon(category != null ? category.getIcon() : "");
vo.setType(category != null ? category.getType()