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:
@@ -20,7 +20,27 @@ request.interceptors.request.use((config) => {
|
|||||||
|
|
||||||
// 响应拦截器:统一处理响应数据和错误
|
// 响应拦截器:统一处理响应数据和错误
|
||||||
request.interceptors.response.use(
|
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
|
const data = response.data
|
||||||
// 如果业务状态码不是 200,视为请求失败
|
// 如果业务状态码不是 200,视为请求失败
|
||||||
if (data.code !== 200) {
|
if (data.code !== 200) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface StatisticsFilterParams {
|
|||||||
types?: string[]
|
types?: string[]
|
||||||
categoryIds?: number[]
|
categoryIds?: number[]
|
||||||
uncategorized?: boolean
|
uncategorized?: boolean
|
||||||
|
untagged?: boolean
|
||||||
paymentMethods?: string[]
|
paymentMethods?: string[]
|
||||||
minAmount?: number
|
minAmount?: number
|
||||||
maxAmount?: number
|
maxAmount?: number
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
// 收支记录相关 API 接口:交易记录的增删改查(分页)
|
// 收支记录相关 API 接口:交易记录的增删改查(分页)
|
||||||
import request from './request'
|
import request from './request'
|
||||||
import axios from 'axios'
|
|
||||||
import { useUserStore } from '../store/user'
|
|
||||||
|
|
||||||
/** 获取系统支持的支付方式编码列表(含账单导入来源与人工支付方式) */
|
/** 获取系统支持的支付方式编码列表(含账单导入来源与人工支付方式) */
|
||||||
export function getPaymentMethods() {
|
export function getPaymentMethods() {
|
||||||
@@ -25,6 +23,7 @@ export function getTransactionPage(params: {
|
|||||||
sortField?: string
|
sortField?: string
|
||||||
sortOrder?: string
|
sortOrder?: string
|
||||||
tagIds?: number[]
|
tagIds?: number[]
|
||||||
|
untagged?: boolean
|
||||||
}) {
|
}) {
|
||||||
return request.get('/transaction/page', { params })
|
return request.get('/transaction/page', { params })
|
||||||
}
|
}
|
||||||
@@ -174,16 +173,13 @@ export function exportTransaction(params: {
|
|||||||
sortField?: string
|
sortField?: string
|
||||||
sortOrder?: string
|
sortOrder?: string
|
||||||
tagIds?: number[]
|
tagIds?: number[]
|
||||||
|
untagged?: boolean
|
||||||
}) {
|
}) {
|
||||||
const userStore = useUserStore()
|
return request.get('/transaction/export', {
|
||||||
return axios.get('/api/transaction/export', {
|
|
||||||
params,
|
params,
|
||||||
paramsSerializer: {
|
paramsSerializer: {
|
||||||
indexes: null,
|
indexes: null,
|
||||||
},
|
},
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${userStore.token}`,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { DataTableColumns } from 'naive-ui'
|
|||||||
import type { ParsedBillRow, BillImportItem } from '../api/transaction'
|
import type { ParsedBillRow, BillImportItem } from '../api/transaction'
|
||||||
import { getCategoryList } from '../api/category'
|
import { getCategoryList } from '../api/category'
|
||||||
import { getTagList } from '../api/tag'
|
import { getTagList } from '../api/tag'
|
||||||
|
import { renderCategoryLabel } from '../utils/render'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** 解析结果行 */
|
/** 解析结果行 */
|
||||||
@@ -34,6 +35,14 @@ const { t } = useI18n()
|
|||||||
// 勾选状态
|
// 勾选状态
|
||||||
const checkedRowNos = ref<number[]>([])
|
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 filterKeyword = ref('')
|
||||||
const filterType = ref<string | null>(null)
|
const filterType = ref<string | null>(null)
|
||||||
@@ -83,7 +92,7 @@ const filteredRows = computed(() => {
|
|||||||
if (!text.includes(kw)) return false
|
if (!text.includes(kw)) return false
|
||||||
}
|
}
|
||||||
if (filterType.value === 'NEUTRAL') {
|
if (filterType.value === 'NEUTRAL') {
|
||||||
if (r.includeInStats) return false
|
if (rowIncludeInStats(r)) return false
|
||||||
} else if (filterType.value && r.type !== filterType.value) {
|
} else if (filterType.value && r.type !== filterType.value) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -143,7 +152,7 @@ const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
|
|||||||
if (!row.importable) {
|
if (!row.importable) {
|
||||||
return h(NTag, { type: 'error', size: 'small' }, { default: () => t('import.notImportable') })
|
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(NTag, { type: 'default', size: 'small' }, { default: () => t('import.notIncluded') })
|
||||||
}
|
}
|
||||||
return h(
|
return h(
|
||||||
@@ -160,7 +169,7 @@ const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
|
|||||||
align: 'center',
|
align: 'center',
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h(NCheckbox, {
|
h(NCheckbox, {
|
||||||
checked: !!row.includeInStats,
|
checked: rowIncludeInStats(row),
|
||||||
disabled: !row.importable,
|
disabled: !row.importable,
|
||||||
onUpdateChecked: (v: boolean) => toggleIncludeInStats(row, v),
|
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() {
|
async function loadTags() {
|
||||||
try {
|
try {
|
||||||
@@ -272,7 +273,7 @@ function toggleAll(checked: boolean) {
|
|||||||
|
|
||||||
/** 切换单行是否计入统计 */
|
/** 切换单行是否计入统计 */
|
||||||
function toggleIncludeInStats(row: ParsedBillRow, value: 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'))
|
message.warning(t('import.selectAtLeastOne'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const next = { ...includeOverrides.value }
|
||||||
checkedRows.value.forEach((row) => {
|
checkedRows.value.forEach((row) => {
|
||||||
row.includeInStats = value
|
next[row.rowNo] = value
|
||||||
})
|
})
|
||||||
|
includeOverrides.value = next
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 确认导入:向父组件抛出勾选行与默认归类 */
|
/** 确认导入:向父组件抛出勾选行与默认归类 */
|
||||||
@@ -299,7 +302,7 @@ function handleConfirm() {
|
|||||||
amount: r.amount,
|
amount: r.amount,
|
||||||
counterparty: r.counterparty,
|
counterparty: r.counterparty,
|
||||||
product: r.product,
|
product: r.product,
|
||||||
includeInStats: r.includeInStats,
|
includeInStats: rowIncludeInStats(r),
|
||||||
rawData: r.rawData,
|
rawData: r.rawData,
|
||||||
}))
|
}))
|
||||||
emit('confirm', {
|
emit('confirm', {
|
||||||
@@ -310,9 +313,10 @@ function handleConfirm() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析结果变化时重置勾选(默认选中解析器建议行)与筛选条件
|
// 解析结果变化时重置勾选(默认选中解析器建议行)、计入统计覆盖与筛选条件
|
||||||
watch(() => props.rows, (rows) => {
|
watch(() => props.rows, (rows) => {
|
||||||
checkedRowNos.value = rows.filter((r) => r.selected).map((r) => r.rowNo)
|
checkedRowNos.value = rows.filter((r) => r.selected).map((r) => r.rowNo)
|
||||||
|
includeOverrides.value = {}
|
||||||
resetFilters()
|
resetFilters()
|
||||||
}, { immediate: true })
|
}, { 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 }
|
||||||
|
}
|
||||||
@@ -630,6 +630,9 @@ export default {
|
|||||||
placeholder: { zh: '用自然语言查询,如:上个月餐饮花了多少', en: 'Ask in natural language, e.g. dining expenses last month', ja: '自然言語で検索(例:先月の食費はいくら)' },
|
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 払い' },
|
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 検索' },
|
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: '検索内容を入力してください' },
|
emptyInput: { zh: '请输入查询内容', en: 'Please enter a query', ja: '検索内容を入力してください' },
|
||||||
clearHistory: { zh: '清空历史', en: 'Clear history', ja: '履歴をクリア' },
|
clearHistory: { zh: '清空历史', en: 'Clear history', ja: '履歴をクリア' },
|
||||||
failed: { zh: 'AI 查询解析失败', en: 'Failed to parse the query', ja: 'AI 検索の解析に失敗しました' },
|
failed: { zh: 'AI 查询解析失败', en: 'Failed to parse the query', ja: 'AI 検索の解析に失敗しました' },
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// 应用入口文件:初始化 Vue 应用,挂载全局插件(状态管理、路由、UI 组件库)
|
// 应用入口文件:初始化 Vue 应用,挂载全局插件(状态管理、路由、UI 组件库)
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import naive from 'naive-ui'
|
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import i18n from './locales'
|
import i18n from './locales'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
@@ -10,6 +9,5 @@ import App from './App.vue'
|
|||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(createPinia()) // 注册 Pinia 状态管理
|
app.use(createPinia()) // 注册 Pinia 状态管理
|
||||||
app.use(router) // 注册路由
|
app.use(router) // 注册路由
|
||||||
app.use(naive) // 全局注册 Naive UI 组件库
|
|
||||||
app.use(i18n) // 注册国际化
|
app.use(i18n) // 注册国际化
|
||||||
app.mount('#app') // 挂载到 DOM 元素 #app
|
app.mount('#app') // 挂载到 DOM 元素 #app
|
||||||
|
|||||||
@@ -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,
|
||||||
|
])
|
||||||
|
}
|
||||||
@@ -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'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ interface CategoryItem {
|
|||||||
// 列表和弹窗状态
|
// 列表和弹窗状态
|
||||||
const categories = ref<CategoryItem[]>([]) // 全部分类列表
|
const categories = ref<CategoryItem[]>([]) // 全部分类列表
|
||||||
const loading = ref(false) // 列表加载状态
|
const loading = ref(false) // 列表加载状态
|
||||||
|
const submitting = ref(false) // 表单提交状态
|
||||||
const showModal = ref(false) // 新增/编辑弹窗显示状态
|
const showModal = ref(false) // 新增/编辑弹窗显示状态
|
||||||
const editingId = ref<number | null>(null) // 当前编辑的分类 ID(null 表示新增)
|
const editingId = ref<number | null>(null) // 当前编辑的分类 ID(null 表示新增)
|
||||||
const form = ref({ name: '', type: 'EXPENSE', icon: '', sortOrder: 0 }) // 表单数据
|
const form = ref({ name: '', type: 'EXPENSE', icon: '', sortOrder: 0 }) // 表单数据
|
||||||
@@ -119,6 +120,7 @@ async function handleSubmit() {
|
|||||||
message.warning(t('category.nameRequired'))
|
message.warning(t('category.nameRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
// 更新已有分类
|
// 更新已有分类
|
||||||
@@ -133,6 +135,8 @@ async function handleSubmit() {
|
|||||||
await fetchData() // 刷新列表
|
await fetchData() // 刷新列表
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || t('common.operationFailed'))
|
message.error(e.message || t('common.operationFailed'))
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +264,7 @@ onMounted(() => {
|
|||||||
<NFormItem :label="t('category.sort')">
|
<NFormItem :label="t('category.sort')">
|
||||||
<NInputNumber v-model:value="form.sortOrder" :min="0" />
|
<NInputNumber v-model:value="form.sortOrder" :min="0" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NButton type="primary" block @click="handleSubmit">{{ t('common.confirm') }}</NButton>
|
<NButton type="primary" block :loading="submitting" @click="handleSubmit">{{ t('common.confirm') }}</NButton>
|
||||||
</NForm>
|
</NForm>
|
||||||
</NModal>
|
</NModal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<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 { useI18n } from 'vue-i18n'
|
||||||
import { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect, NDatePicker, NInput, NInputNumber, NButton } from 'naive-ui'
|
import { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect, NDatePicker, NInput, NInputNumber, NButton } from 'naive-ui'
|
||||||
import VChart from 'vue-echarts'
|
import VChart from 'vue-echarts'
|
||||||
@@ -12,6 +12,7 @@ import { getStatisticsSummary, getStatisticsGroup } from '../api/statistics'
|
|||||||
import { getCategoryList } from '../api/category'
|
import { getCategoryList } from '../api/category'
|
||||||
import { getPaymentMethods } from '../api/transaction'
|
import { getPaymentMethods } from '../api/transaction'
|
||||||
import { getPaymentLabel } from '../utils/payment'
|
import { getPaymentLabel } from '../utils/payment'
|
||||||
|
import { renderCategoryLabel } from '../utils/render'
|
||||||
|
|
||||||
// 注册 ECharts 所需组件
|
// 注册 ECharts 所需组件
|
||||||
use([CanvasRenderer, LineChart, PieChart, BarChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
|
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)),
|
...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与未分类标记 */
|
/** 将分类筛选拆分为分类ID与未分类标记 */
|
||||||
function buildCategoryFilter() {
|
function buildCategoryFilter() {
|
||||||
const ids = filterCategory.value.filter((v) => v !== 0)
|
const ids = filterCategory.value.filter((v) => v !== 0)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import {
|
import {
|
||||||
useMessage, useDialog, NSpace, NButton, NDataTable, NTag, NModal, NForm, NFormItem,
|
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'
|
} from 'naive-ui'
|
||||||
import type { DataTableColumns } from 'naive-ui'
|
import type { DataTableColumns } from 'naive-ui'
|
||||||
import {
|
import {
|
||||||
@@ -23,16 +23,16 @@ import type {
|
|||||||
import type { ParsedBillRow, BillType, BillImportItem } from '../api/transaction'
|
import type { ParsedBillRow, BillType, BillImportItem } from '../api/transaction'
|
||||||
import { getCategoryList } from '../api/category'
|
import { getCategoryList } from '../api/category'
|
||||||
import { getTagList } from '../api/tag'
|
import { getTagList } from '../api/tag'
|
||||||
|
import { renderCategoryLabel } from '../utils/render'
|
||||||
import BillPreviewPanel from '../components/BillPreviewPanel.vue'
|
import BillPreviewPanel from '../components/BillPreviewPanel.vue'
|
||||||
import BillImportResult from '../components/BillImportResult.vue'
|
import BillImportResult from '../components/BillImportResult.vue'
|
||||||
import { useUserStore } from '../store/user'
|
import { openSse } from '../utils/sse'
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
|
||||||
|
|
||||||
/** 待处理列表自动刷新间隔(毫秒) */
|
/** 待处理列表自动刷新间隔(毫秒) */
|
||||||
const MESSAGE_REFRESH_MS = 30000
|
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 incomeCategories = ref<{ label: string; value: number; icon: string }[]>([])
|
||||||
const allTags = ref<{ label: string; value: number }[]>([])
|
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() {
|
async function loadOptions() {
|
||||||
try {
|
try {
|
||||||
const [expenseRes, incomeRes, tagRes]: any = await Promise.all([
|
const [expenseRes, incomeRes, tagRes]: any = await Promise.all([
|
||||||
@@ -104,11 +96,12 @@ const configLoading = ref(false)
|
|||||||
const configModalVisible = ref(false)
|
const configModalVisible = ref(false)
|
||||||
const configEditingId = ref<number | null>(null)
|
const configEditingId = ref<number | null>(null)
|
||||||
const configForm = ref<EmailBillConfigPayload>(emptyConfigForm())
|
const configForm = ref<EmailBillConfigPayload>(emptyConfigForm())
|
||||||
|
const configSaving = ref(false) // 邮箱配置保存状态
|
||||||
|
|
||||||
// ===== 立即检查实时进度 =====
|
// ===== 立即检查实时进度 =====
|
||||||
const checkLogs = ref<string[]>([])
|
const checkLogs = ref<string[]>([])
|
||||||
const checkRunning = ref(false)
|
const checkRunning = ref(false)
|
||||||
let checkSource: EventSource | null = null
|
let checkController: AbortController | null = null
|
||||||
let checkFinished = false
|
let checkFinished = false
|
||||||
|
|
||||||
function emptyConfigForm(): EmailBillConfigPayload {
|
function emptyConfigForm(): EmailBillConfigPayload {
|
||||||
@@ -206,6 +199,7 @@ function openConfigModal(row?: EmailBillConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveConfig() {
|
async function saveConfig() {
|
||||||
|
configSaving.value = true
|
||||||
try {
|
try {
|
||||||
if (configEditingId.value) {
|
if (configEditingId.value) {
|
||||||
await updateEmailBillConfig(configEditingId.value, configForm.value)
|
await updateEmailBillConfig(configEditingId.value, configForm.value)
|
||||||
@@ -217,6 +211,8 @@ async function saveConfig() {
|
|||||||
await loadConfigs()
|
await loadConfigs()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || t('emailBill.common.loadFailed'))
|
message.error(e.message || t('emailBill.common.loadFailed'))
|
||||||
|
} finally {
|
||||||
|
configSaving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,46 +260,45 @@ function handleCheckConfig(row: EmailBillConfig) {
|
|||||||
checkRunning.value = true
|
checkRunning.value = true
|
||||||
checkFinished = false
|
checkFinished = false
|
||||||
|
|
||||||
const params = new URLSearchParams()
|
checkController = openSse(`/api/email-bill/configs/${row.id}/check/stream`, {
|
||||||
// EventSource 无法自定义请求头,token 通过查询参数传递
|
onEvent: async (event, data) => {
|
||||||
params.set('token', userStore.token || '')
|
if (event === 'line') {
|
||||||
const source = new EventSource(`/api/email-bill/configs/${row.id}/check/stream?${params.toString()}`)
|
checkLogs.value.push(data)
|
||||||
checkSource = source
|
} else if (event === 'done') {
|
||||||
|
let count = 0
|
||||||
source.addEventListener('line', (e: MessageEvent) => {
|
try {
|
||||||
checkLogs.value.push(e.data)
|
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 连接 */
|
/** 结束检查状态并关闭 SSE 连接 */
|
||||||
@@ -315,9 +310,9 @@ function finishCheck() {
|
|||||||
|
|
||||||
/** 关闭当前 SSE 连接 */
|
/** 关闭当前 SSE 连接 */
|
||||||
function stopCheckStream() {
|
function stopCheckStream() {
|
||||||
if (checkSource) {
|
if (checkController) {
|
||||||
checkSource.close()
|
checkController.abort()
|
||||||
checkSource = null
|
checkController = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +321,8 @@ const senderRules = ref<EmailBillSenderRule[]>([])
|
|||||||
const ruleLoading = ref(false)
|
const ruleLoading = ref(false)
|
||||||
const ruleModalVisible = ref(false)
|
const ruleModalVisible = ref(false)
|
||||||
const ruleEditingId = ref<number | null>(null)
|
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() {
|
function emptyRuleForm() {
|
||||||
return {
|
return {
|
||||||
@@ -410,6 +406,7 @@ async function saveRule() {
|
|||||||
incomeCategoryId: ruleForm.value.incomeCategoryId,
|
incomeCategoryId: ruleForm.value.incomeCategoryId,
|
||||||
tagIds: ruleForm.value.tagIds,
|
tagIds: ruleForm.value.tagIds,
|
||||||
}
|
}
|
||||||
|
ruleSaving.value = true
|
||||||
try {
|
try {
|
||||||
if (ruleEditingId.value) {
|
if (ruleEditingId.value) {
|
||||||
await updateEmailBillSenderRule(ruleEditingId.value, payload)
|
await updateEmailBillSenderRule(ruleEditingId.value, payload)
|
||||||
@@ -421,6 +418,8 @@ async function saveRule() {
|
|||||||
await loadSenderRules()
|
await loadSenderRules()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message)
|
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>
|
<NButton v-else size="tiny" @click="checkLogs = []">{{ t('emailBill.config.checkClear') }}</NButton>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</template>
|
</template>
|
||||||
<NLog :rows="checkLogs" :loading="checkRunning" style="max-height: 240px;" />
|
<NLog :lines="checkLogs" :loading="checkRunning" style="max-height: 240px;" />
|
||||||
</NCard>
|
</NCard>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</NTabPane>
|
</NTabPane>
|
||||||
@@ -801,7 +800,7 @@ onUnmounted(() => {
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<NSpace justify="end">
|
<NSpace justify="end">
|
||||||
<NButton @click="configModalVisible = false">{{ t('emailBill.common.cancel') }}</NButton>
|
<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>
|
</NSpace>
|
||||||
</template>
|
</template>
|
||||||
</NModal>
|
</NModal>
|
||||||
@@ -840,7 +839,7 @@ onUnmounted(() => {
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<NSpace justify="end">
|
<NSpace justify="end">
|
||||||
<NButton @click="ruleModalVisible = false">{{ t('emailBill.common.cancel') }}</NButton>
|
<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>
|
</NSpace>
|
||||||
</template>
|
</template>
|
||||||
</NModal>
|
</NModal>
|
||||||
|
|||||||
+32
-39
@@ -11,11 +11,10 @@ import type { DataTableColumns } from 'naive-ui'
|
|||||||
import { RefreshOutline, SearchOutline } from '@vicons/ionicons5'
|
import { RefreshOutline, SearchOutline } from '@vicons/ionicons5'
|
||||||
import { pageHttpLogs, pageLoginLogs, getAppLog, getLogRetention, updateLogRetention } from '../api/log'
|
import { pageHttpLogs, pageLoginLogs, getAppLog, getLogRetention, updateLogRetention } from '../api/log'
|
||||||
import type { HttpLog, LoginLog, AppLog } from '../api/log'
|
import type { HttpLog, LoginLog, AppLog } from '../api/log'
|
||||||
import { useUserStore } from '../store/user'
|
import { openSse } from '../utils/sse'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -66,8 +65,8 @@ const appLines = ref<number | null>(500)
|
|||||||
const appRange = ref(3600000)
|
const appRange = ref(3600000)
|
||||||
// 实时获取:开启后通过 SSE 订阅日志新增内容
|
// 实时获取:开启后通过 SSE 订阅日志新增内容
|
||||||
const appRealtime = ref(false)
|
const appRealtime = ref(false)
|
||||||
// SSE 连接
|
// SSE 连接控制器
|
||||||
let eventSource: EventSource | null = null
|
let sseController: AbortController | null = null
|
||||||
|
|
||||||
/** 实时模式下缓冲的最大行数 */
|
/** 实时模式下缓冲的最大行数 */
|
||||||
const MAX_REALTIME_LINES = 2000
|
const MAX_REALTIME_LINES = 2000
|
||||||
@@ -253,9 +252,9 @@ async function loadApp() {
|
|||||||
|
|
||||||
/** 关闭 SSE 实时连接 */
|
/** 关闭 SSE 实时连接 */
|
||||||
function stopRealtime() {
|
function stopRealtime() {
|
||||||
if (eventSource) {
|
if (sseController) {
|
||||||
eventSource.close()
|
sseController.abort()
|
||||||
eventSource = null
|
sseController = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,8 +263,6 @@ function startRealtime() {
|
|||||||
stopRealtime()
|
stopRealtime()
|
||||||
|
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
// EventSource 无法自定义请求头,token 通过查询参数传递
|
|
||||||
params.set('token', userStore.token || '')
|
|
||||||
if (appLevel.value) {
|
if (appLevel.value) {
|
||||||
params.set('level', appLevel.value)
|
params.set('level', appLevel.value)
|
||||||
}
|
}
|
||||||
@@ -278,41 +275,37 @@ function startRealtime() {
|
|||||||
params.set('since', String(since))
|
params.set('since', String(since))
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = new EventSource(`/api/log/app/stream?${params.toString()}`)
|
sseController = openSse(`/api/log/app/stream?${params.toString()}`, {
|
||||||
eventSource = source
|
// 元信息事件:初始化文件名与初始命中行数
|
||||||
|
onEvent: (event, data) => {
|
||||||
// 元信息事件:初始化文件名与初始命中行数
|
if (event === 'meta') {
|
||||||
source.addEventListener('meta', (e: MessageEvent) => {
|
try {
|
||||||
try {
|
const meta = JSON.parse(data)
|
||||||
const meta = JSON.parse(e.data)
|
appData.value = { file: meta.file || '', total: meta.total || 0, lines: [] }
|
||||||
appData.value = { file: meta.file || '', total: meta.total || 0, lines: [] }
|
} catch {
|
||||||
} catch {
|
appData.value = { file: '', total: 0, lines: [] }
|
||||||
appData.value = { file: '', total: 0, lines: [] }
|
}
|
||||||
}
|
} else if (event === 'line') {
|
||||||
})
|
// 日志行事件:按时间正序推送,插入列表头部实现最新在前
|
||||||
|
if (!appData.value) {
|
||||||
// 日志行事件:按时间正序推送,插入列表头部实现最新在前
|
appData.value = { file: '', total: 0, lines: [] }
|
||||||
source.addEventListener('line', (e: MessageEvent) => {
|
}
|
||||||
if (!appData.value) {
|
appData.value.lines.unshift(data)
|
||||||
appData.value = { file: '', total: 0, lines: [] }
|
appData.value.total++
|
||||||
}
|
if (appData.value.lines.length > MAX_REALTIME_LINES) {
|
||||||
appData.value.lines.unshift(e.data)
|
appData.value.lines.length = MAX_REALTIME_LINES
|
||||||
appData.value.total++
|
}
|
||||||
if (appData.value.lines.length > MAX_REALTIME_LINES) {
|
}
|
||||||
appData.value.lines.length = MAX_REALTIME_LINES
|
},
|
||||||
}
|
onError: () => {
|
||||||
})
|
// 认证失败或连接异常时停止实时并提示,避免无限重连
|
||||||
|
|
||||||
source.onerror = () => {
|
|
||||||
// 服务端关闭连接(如认证失败)时停止实时并提示,避免无限重连
|
|
||||||
if (source.readyState === EventSource.CLOSED) {
|
|
||||||
stopRealtime()
|
stopRealtime()
|
||||||
if (appRealtime.value) {
|
if (appRealtime.value) {
|
||||||
appRealtime.value = false
|
appRealtime.value = false
|
||||||
message.error(t('log.loadFailed'))
|
message.error(t('log.loadFailed'))
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 实时开关变化时建立/关闭 SSE 连接(仅在运行日志标签页生效)
|
// 实时开关变化时建立/关闭 SSE 连接(仅在运行日志标签页生效)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { getRuleList, createRule, updateRule, deleteRule, toggleRuleEnabled } fr
|
|||||||
import type { RuleItem } from '../api/rule'
|
import type { RuleItem } from '../api/rule'
|
||||||
import { getCategoryList } from '../api/category'
|
import { getCategoryList } from '../api/category'
|
||||||
import { getTagList } from '../api/tag'
|
import { getTagList } from '../api/tag'
|
||||||
|
import { renderCategoryLabel } from '../utils/render'
|
||||||
|
|
||||||
const message = useMessage() // 消息提示
|
const message = useMessage() // 消息提示
|
||||||
const dialog = useDialog() // 对话框
|
const dialog = useDialog() // 对话框
|
||||||
@@ -79,14 +80,6 @@ const categoryOptions = computed(() => {
|
|||||||
// 标签选项
|
// 标签选项
|
||||||
const tagOptions = computed(() => tags.value.map((tag) => ({ label: tag.label, value: tag.value })))
|
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) {
|
function conditionTags(row: RuleItem) {
|
||||||
const result: any[] = []
|
const result: any[] = []
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface TagItem {
|
|||||||
// 列表和弹窗状态
|
// 列表和弹窗状态
|
||||||
const tags = ref<TagItem[]>([]) // 标签列表
|
const tags = ref<TagItem[]>([]) // 标签列表
|
||||||
const loading = ref(false) // 列表加载状态
|
const loading = ref(false) // 列表加载状态
|
||||||
|
const submitting = ref(false) // 表单提交状态
|
||||||
const showModal = ref(false) // 新增/编辑弹窗显示状态
|
const showModal = ref(false) // 新增/编辑弹窗显示状态
|
||||||
const editingId = ref<number | null>(null) // 当前编辑的标签 ID(null 表示新增)
|
const editingId = ref<number | null>(null) // 当前编辑的标签 ID(null 表示新增)
|
||||||
const form = ref<{ name: string; color: string; sortOrder: number }>({ name: '', color: '#18a058', sortOrder: 0 }) // 表单数据
|
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'))
|
message.warning(t('tag.nameRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
await updateTag(editingId.value, form.value)
|
await updateTag(editingId.value, form.value)
|
||||||
@@ -122,6 +124,8 @@ async function handleSubmit() {
|
|||||||
await fetchData()
|
await fetchData()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || t('common.operationFailed'))
|
message.error(e.message || t('common.operationFailed'))
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +209,7 @@ onMounted(() => {
|
|||||||
<NFormItem :label="t('tag.sort')">
|
<NFormItem :label="t('tag.sort')">
|
||||||
<NInputNumber v-model:value="form.sortOrder" :min="0" />
|
<NInputNumber v-model:value="form.sortOrder" :min="0" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NButton type="primary" block @click="handleSubmit">{{ t('common.confirm') }}</NButton>
|
<NButton type="primary" block :loading="submitting" @click="handleSubmit">{{ t('common.confirm') }}</NButton>
|
||||||
</NForm>
|
</NForm>
|
||||||
</NModal>
|
</NModal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,14 +3,16 @@
|
|||||||
import { ref, onMounted, computed, h, watch } from 'vue'
|
import { ref, onMounted, computed, h, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
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 { 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 { EyeOutline, EyeOffOutline, SparklesOutline, BulbOutline } from '@vicons/ionicons5'
|
||||||
import type { DataTableColumns, AutoCompleteOption } from 'naive-ui'
|
import type { DataTableColumns } from 'naive-ui'
|
||||||
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags, getPaymentMethods, batchDeleteTransactions } from '../api/transaction'
|
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 { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi } from '../api/ai'
|
||||||
import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai'
|
import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai'
|
||||||
import { getCategoryList, createCategory } from '../api/category'
|
import { getCategoryList, createCategory } from '../api/category'
|
||||||
import { getTagList, createTag } from '../api/tag'
|
import { getTagList, createTag } from '../api/tag'
|
||||||
import { getPaymentLabel } from '../utils/payment'
|
import { getPaymentLabel } from '../utils/payment'
|
||||||
|
import { renderCategoryLabel } from '../utils/render'
|
||||||
|
import { useKeywordHistory } from '../composables/useKeywordHistory'
|
||||||
import BillImportModal from './BillImportModal.vue'
|
import BillImportModal from './BillImportModal.vue'
|
||||||
|
|
||||||
const message = useMessage() // 消息提示
|
const message = useMessage() // 消息提示
|
||||||
@@ -70,9 +72,9 @@ const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.v
|
|||||||
const pageSummary = computed(() => {
|
const pageSummary = computed(() => {
|
||||||
let income = 0
|
let income = 0
|
||||||
let expense = 0
|
let expense = 0
|
||||||
for (const t of transactions.value) {
|
for (const tx of transactions.value) {
|
||||||
const amount = Number(t.amount) || 0
|
const amount = Number(tx.amount) || 0
|
||||||
if (t.type === 'INCOME') income += amount
|
if (tx.type === 'INCOME') income += amount
|
||||||
else expense += amount
|
else expense += amount
|
||||||
}
|
}
|
||||||
return { income, expense, balance: income - expense }
|
return { income, expense, balance: income - expense }
|
||||||
@@ -81,15 +83,16 @@ const pageSummary = computed(() => {
|
|||||||
const pageExcludedSummary = computed(() => {
|
const pageExcludedSummary = computed(() => {
|
||||||
let income = 0
|
let income = 0
|
||||||
let expense = 0
|
let expense = 0
|
||||||
for (const t of transactions.value) {
|
for (const tx of transactions.value) {
|
||||||
if (t.includeInStats) continue
|
if (tx.includeInStats) continue
|
||||||
const amount = Number(t.amount) || 0
|
const amount = Number(tx.amount) || 0
|
||||||
if (t.type === 'INCOME') income += amount
|
if (tx.type === 'INCOME') income += amount
|
||||||
else expense += amount
|
else expense += amount
|
||||||
}
|
}
|
||||||
return { income, expense }
|
return { income, expense }
|
||||||
})
|
})
|
||||||
const loading = ref(false) // 列表加载状态
|
const loading = ref(false) // 列表加载状态
|
||||||
|
const submitting = ref(false) // 表单提交状态
|
||||||
const showModal = ref(false) // 新增/编辑弹窗显示状态
|
const showModal = ref(false) // 新增/编辑弹窗显示状态
|
||||||
const showImportModal = ref(false) // 账单导入弹窗显示状态
|
const showImportModal = ref(false) // 账单导入弹窗显示状态
|
||||||
const showBatchModal = ref(false) // 批量设置分类弹窗显示状态
|
const showBatchModal = ref(false) // 批量设置分类弹窗显示状态
|
||||||
@@ -130,7 +133,8 @@ const aiNewLoading = ref(false) // 编辑弹窗内 AI 生成新分类状态
|
|||||||
const aiNewSaving = ref(false) // 新分类保存状态
|
const aiNewSaving = ref(false) // 新分类保存状态
|
||||||
// AI 生成但尚未保存的新分类,需用户确认后再落库
|
// AI 生成但尚未保存的新分类,需用户确认后再落库
|
||||||
const pendingCategory = ref<{ name: string; type: string; icon: string } | null>(null)
|
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) // 新标签保存状态
|
const aiTagSaving = ref(false) // 新标签保存状态
|
||||||
// AI 生成但尚未保存的新标签,需用户确认后再落库
|
// AI 生成但尚未保存的新标签,需用户确认后再落库
|
||||||
const pendingTag = ref<{ name: string; color: string } | null>(null)
|
const pendingTag = ref<{ name: string; color: string } | null>(null)
|
||||||
@@ -140,12 +144,15 @@ const checkedAiRowKeys = ref<number[]>([]) // AI 建议弹窗中已勾选的代
|
|||||||
const aiQueryText = ref('') // AI 自然语言查询输入
|
const aiQueryText = ref('') // AI 自然语言查询输入
|
||||||
const aiQuerying = ref(false) // AI 查询解析状态
|
const aiQuerying = ref(false) // AI 查询解析状态
|
||||||
const aiQueryResult = ref<AiQueryResult | null>(null) // 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_KEY = 'finance.transaction.aiQueryHistory'
|
||||||
const AI_QUERY_HISTORY_MAX = 10
|
// AI 查询历史(最近使用,最新在前)
|
||||||
// 下拉中“清空历史”选项的哨兵值
|
const aiQueryHistory = useKeywordHistory({
|
||||||
const CLEAR_AI_QUERY_HISTORY = '__clear_ai_query_history__'
|
storageKey: AI_QUERY_HISTORY_KEY,
|
||||||
const aiQueryHistory = ref<string[]>([]) // AI 查询历史(最近使用,最新在前)
|
text: aiQueryText,
|
||||||
|
clearLabel: () => t('ai.query.clearHistory'),
|
||||||
|
})
|
||||||
const showAiRecordModal = ref(false) // AI 记账弹窗显示状态
|
const showAiRecordModal = ref(false) // AI 记账弹窗显示状态
|
||||||
const aiRecordText = ref('') // AI 记账输入
|
const aiRecordText = ref('') // AI 记账输入
|
||||||
const aiRecording = ref(false) // AI 记账解析状态
|
const aiRecording = ref(false) // AI 记账解析状态
|
||||||
@@ -158,8 +165,8 @@ const batchAddTagIds = ref<number[]>([]) // 批量添加的标签
|
|||||||
const batchRemoveTagIds = ref<number[]>([]) // 批量移除的标签
|
const batchRemoveTagIds = ref<number[]>([]) // 批量移除的标签
|
||||||
const batching = ref(false) // 批量设置提交状态
|
const batching = ref(false) // 批量设置提交状态
|
||||||
// 当前表格是否包含收入/支出记录,用于按需展示批量分类项
|
// 当前表格是否包含收入/支出记录,用于按需展示批量分类项
|
||||||
const hasIncomeRecords = computed(() => transactions.value.some((t) => t.type === 'INCOME'))
|
const hasIncomeRecords = computed(() => transactions.value.some((tx) => tx.type === 'INCOME'))
|
||||||
const hasExpenseRecords = computed(() => transactions.value.some((t) => t.type === 'EXPENSE'))
|
const hasExpenseRecords = computed(() => transactions.value.some((tx) => tx.type === 'EXPENSE'))
|
||||||
|
|
||||||
// 批量设置计入统计选项
|
// 批量设置计入统计选项
|
||||||
const batchStatsOptions = computed(() => [
|
const batchStatsOptions = computed(() => [
|
||||||
@@ -206,12 +213,14 @@ const filterPaymentMethod = ref<string[]>([]) // 支付方式筛选(多选)
|
|||||||
const filterDateRange = ref<[number, number] | null>(null) // 日期范围筛选(起止时间戳)
|
const filterDateRange = ref<[number, number] | null>(null) // 日期范围筛选(起止时间戳)
|
||||||
const filterIncludeInStats = ref<string | null>(null) // 计入统计筛选:null-全部,'true'-计入,'false'-不计入
|
const filterIncludeInStats = ref<string | null>(null) // 计入统计筛选:null-全部,'true'-计入,'false'-不计入
|
||||||
const filterRemark = ref('') // 备注关键字搜索
|
const filterRemark = ref('') // 备注关键字搜索
|
||||||
// 备注关键词历史存储键与上限
|
// 备注关键词历史存储键
|
||||||
const REMARK_HISTORY_KEY = 'finance.transaction.remarkHistory'
|
const REMARK_HISTORY_KEY = 'finance.transaction.remarkHistory'
|
||||||
const REMARK_HISTORY_MAX = 10
|
// 备注关键词历史(最近输入,最新在前)
|
||||||
// 下拉中“清空历史”选项的哨兵值
|
const remarkHistory = useKeywordHistory({
|
||||||
const CLEAR_REMARK_HISTORY = '__clear_remark_history__'
|
storageKey: REMARK_HISTORY_KEY,
|
||||||
const remarkHistory = ref<string[]>([]) // 备注关键词历史(最近输入,最新在前)
|
text: filterRemark,
|
||||||
|
clearLabel: () => t('transaction.remarkHistoryClear'),
|
||||||
|
})
|
||||||
const filterMinAmount = ref<number | null>(null) // 最小金额筛选
|
const filterMinAmount = ref<number | null>(null) // 最小金额筛选
|
||||||
const filterMaxAmount = ref<number | null>(null) // 最大金额筛选
|
const filterMaxAmount = ref<number | null>(null) // 最大金额筛选
|
||||||
const filterTag = ref<number[]>([]) // 标签筛选(多选)
|
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(() => [
|
const tagFilterSelectOptions = computed(() => [
|
||||||
@@ -326,67 +335,10 @@ watch(
|
|||||||
{ deep: true },
|
{ 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) {
|
function handleRemarkSelect(value: string) {
|
||||||
if (value === CLEAR_REMARK_HISTORY) {
|
if (value === remarkHistory.clearValue) {
|
||||||
clearRemarkHistory()
|
remarkHistory.clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
filterRemark.value = value
|
filterRemark.value = value
|
||||||
@@ -395,34 +347,18 @@ function handleRemarkSelect(value: string) {
|
|||||||
|
|
||||||
/** 失焦时记录当前备注关键词 */
|
/** 失焦时记录当前备注关键词 */
|
||||||
function handleRemarkBlur() {
|
function handleRemarkBlur() {
|
||||||
recordRemarkKeyword()
|
remarkHistory.record()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 回车时记录关键词并搜索(阻止 NAutoComplete 内部选中建议项,保持原回车搜索语义) */
|
/** 回车时记录关键词并搜索(阻止 NAutoComplete 内部选中建议项,保持原回车搜索语义) */
|
||||||
function handleRemarkKeydown(e: KeyboardEvent) {
|
function handleRemarkKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
recordRemarkKeyword()
|
remarkHistory.record()
|
||||||
handleSearch()
|
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) */
|
/** 将时间戳格式化为 ISO 格式的日期时间字符串(YYYY-MM-DDTHH:mm:ss) */
|
||||||
function formatTimestamp(ts: number, isEnd = false): string {
|
function formatTimestamp(ts: number, isEnd = false): string {
|
||||||
const d = new Date(ts)
|
const d = new Date(ts)
|
||||||
@@ -432,12 +368,11 @@ function formatTimestamp(ts: number, isEnd = false): string {
|
|||||||
return `${date}T${time}`
|
return `${date}T${time}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 分类下拉项渲染:图标 + 名称(下拉选项与已选项均生效) */
|
/** 将时间戳格式化为本地日期时间字符串(YYYY-MM-DDTHH:mm:ss),避免 UTC 转换导致时区偏移 */
|
||||||
function renderCategoryLabel(option: any) {
|
function formatLocalDateTime(ts: number): string {
|
||||||
return h('span', null, [
|
const d = new Date(ts)
|
||||||
option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null,
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
option.label,
|
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 || '',
|
remark: row.remark || '',
|
||||||
transactionTime: new Date(row.transactionTime).getTime(),
|
transactionTime: new Date(row.transactionTime).getTime(),
|
||||||
includeInStats: row.includeInStats ?? true,
|
includeInStats: row.includeInStats ?? true,
|
||||||
tagIds: (row.tags || []).map((t) => t.id),
|
tagIds: (row.tags || []).map((tag) => tag.id),
|
||||||
}
|
}
|
||||||
pendingCategory.value = null
|
pendingCategory.value = null
|
||||||
pendingTag.value = null
|
pendingTag.value = null
|
||||||
@@ -713,10 +648,11 @@ async function handleSubmit() {
|
|||||||
amount: form.value.amount!,
|
amount: form.value.amount!,
|
||||||
paymentMethod: form.value.paymentMethod || undefined,
|
paymentMethod: form.value.paymentMethod || undefined,
|
||||||
remark: form.value.remark || 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,
|
includeInStats: form.value.includeInStats,
|
||||||
tagIds: form.value.tagIds,
|
tagIds: form.value.tagIds,
|
||||||
}
|
}
|
||||||
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
await updateTransaction(editingId.value, data)
|
await updateTransaction(editingId.value, data)
|
||||||
@@ -729,6 +665,8 @@ async function handleSubmit() {
|
|||||||
await fetchData()
|
await fetchData()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || t('common.operationFailed'))
|
message.error(e.message || t('common.operationFailed'))
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -840,7 +778,7 @@ async function handleAiSuggest() {
|
|||||||
}
|
}
|
||||||
aiLoading.value = true
|
aiLoading.value = true
|
||||||
try {
|
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)
|
const res: any = await suggestByAi(ids, aiScope.value)
|
||||||
aiSuggestions.value = res.data || []
|
aiSuggestions.value = res.data || []
|
||||||
if (aiSuggestions.value.length === 0) {
|
if (aiSuggestions.value.length === 0) {
|
||||||
@@ -951,7 +889,7 @@ async function handleSaveNewCategory() {
|
|||||||
|
|
||||||
/** 编辑弹窗内 AI 推荐已有标签:基于表单当前内容,追加到已选标签 */
|
/** 编辑弹窗内 AI 推荐已有标签:基于表单当前内容,追加到已选标签 */
|
||||||
async function handleAiTagSuggest() {
|
async function handleAiTagSuggest() {
|
||||||
aiTagLoading.value = true
|
aiTagSuggestLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res: any = await suggestOneByAi({
|
const res: any = await suggestOneByAi({
|
||||||
type: form.value.type,
|
type: form.value.type,
|
||||||
@@ -970,7 +908,7 @@ async function handleAiTagSuggest() {
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || t('ai.suggestFailed'))
|
message.error(e.message || t('ai.suggestFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
aiTagLoading.value = false
|
aiTagSuggestLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -980,7 +918,7 @@ async function handleAiNewTag() {
|
|||||||
message.warning(t('ai.newTag.needInfo'))
|
message.warning(t('ai.newTag.needInfo'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
aiTagLoading.value = true
|
aiTagNewLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res: any = await parseTagByAi(buildCategoryPrompt())
|
const res: any = await parseTagByAi(buildCategoryPrompt())
|
||||||
const data = res.data
|
const data = res.data
|
||||||
@@ -992,7 +930,7 @@ async function handleAiNewTag() {
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || t('ai.newTag.failed'))
|
message.error(e.message || t('ai.newTag.failed'))
|
||||||
} finally {
|
} 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 解析自然语言查询 */
|
/** 请求 AI 解析自然语言查询 */
|
||||||
async function handleAiQuery() {
|
async function handleAiQuery() {
|
||||||
if (!aiEnabled.value) {
|
if (!aiEnabled.value) {
|
||||||
@@ -1146,7 +1093,7 @@ async function handleAiQuery() {
|
|||||||
message.warning(t('ai.query.emptyInput'))
|
message.warning(t('ai.query.emptyInput'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordAiQueryHistory()
|
aiQueryHistory.record()
|
||||||
aiQuerying.value = true
|
aiQuerying.value = true
|
||||||
try {
|
try {
|
||||||
const res: any = await queryByAi(text)
|
const res: any = await queryByAi(text)
|
||||||
@@ -1158,6 +1105,7 @@ async function handleAiQuery() {
|
|||||||
}
|
}
|
||||||
// 直接应用解析结果并搜索,无需二次确认
|
// 直接应用解析结果并搜索,无需二次确认
|
||||||
applyAiQuery()
|
applyAiQuery()
|
||||||
|
showAiQueryModal.value = false
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || t('ai.query.failed'))
|
message.error(e.message || t('ai.query.failed'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1214,67 +1162,10 @@ function handleExampleSelect(key: string) {
|
|||||||
aiQueryText.value = key
|
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) {
|
function handleAiQuerySelect(value: string) {
|
||||||
if (value === CLEAR_AI_QUERY_HISTORY) {
|
if (value === aiQueryHistory.clearValue) {
|
||||||
clearAiQueryHistory()
|
aiQueryHistory.clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
aiQueryText.value = value
|
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 */
|
/** 将时间戳格式化为 yyyy-MM-dd */
|
||||||
function formatDateLabel(ts: number) {
|
function formatDateLabel(ts: number) {
|
||||||
const d = new Date(ts)
|
const d = new Date(ts)
|
||||||
@@ -1399,7 +1274,7 @@ function applyTagClear(tag: { clear: () => void }) {
|
|||||||
const aiRows = computed(() =>
|
const aiRows = computed(() =>
|
||||||
aiSuggestions.value.map((s) => ({
|
aiSuggestions.value.map((s) => ({
|
||||||
...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
|
batching.value = true
|
||||||
try {
|
try {
|
||||||
const ids = transactions.value.map((t) => t.id)
|
const ids = transactions.value.map((tx) => tx.id)
|
||||||
if (hasCategoryOrStats) {
|
if (hasCategoryOrStats) {
|
||||||
await batchUpdateCategory({
|
await batchUpdateCategory({
|
||||||
ids,
|
ids,
|
||||||
@@ -1607,8 +1482,8 @@ async function doExport() {
|
|||||||
// 组件挂载时加载分类和交易记录
|
// 组件挂载时加载分类和交易记录
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadFilterPrefs()
|
loadFilterPrefs()
|
||||||
loadRemarkHistory()
|
remarkHistory.load()
|
||||||
loadAiQueryHistory()
|
aiQueryHistory.load()
|
||||||
loadAiConfig()
|
loadAiConfig()
|
||||||
fetchCategories()
|
fetchCategories()
|
||||||
fetchTags()
|
fetchTags()
|
||||||
@@ -1644,39 +1519,6 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- 查询与筛选 -->
|
<!-- 查询与筛选 -->
|
||||||
<NCard :bordered="false" style="margin-bottom: 16px;">
|
<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]">
|
<NSpace align="center" :wrap="true" :size="[12, 8]">
|
||||||
<NSelect
|
<NSelect
|
||||||
@@ -1708,14 +1550,14 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
<NAutoComplete
|
<NAutoComplete
|
||||||
:value="filterRemark"
|
:value="filterRemark"
|
||||||
:options="remarkOptions"
|
:options="remarkHistory.options.value"
|
||||||
:get-show="showRemarkMenu"
|
:get-show="remarkHistory.showMenu"
|
||||||
:render-label="renderRemarkLabel"
|
:render-label="remarkHistory.renderLabel"
|
||||||
:input-props="{ onKeydown: handleRemarkKeydown }"
|
:input-props="{ onKeydown: handleRemarkKeydown }"
|
||||||
clearable
|
clearable
|
||||||
:placeholder="t('transaction.column.remark')"
|
:placeholder="t('transaction.column.remark')"
|
||||||
style="width: 180px;"
|
style="width: 180px;"
|
||||||
@update:value="handleRemarkInput"
|
@update:value="remarkHistory.handleInput"
|
||||||
@select="handleRemarkSelect"
|
@select="handleRemarkSelect"
|
||||||
@blur="handleRemarkBlur"
|
@blur="handleRemarkBlur"
|
||||||
/>
|
/>
|
||||||
@@ -1790,6 +1632,12 @@ onMounted(() => {
|
|||||||
</NPopover>
|
</NPopover>
|
||||||
<NButton type="primary" @click="handleSearch">{{ t('transaction.search') }}</NButton>
|
<NButton type="primary" @click="handleSearch">{{ t('transaction.search') }}</NButton>
|
||||||
<NButton @click="handleReset">{{ t('transaction.reset') }}</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>
|
</NSpace>
|
||||||
|
|
||||||
<!-- 已激活的筛选条件 -->
|
<!-- 已激活的筛选条件 -->
|
||||||
@@ -1893,8 +1741,8 @@ onMounted(() => {
|
|||||||
:placeholder="t('transaction.tagPlaceholder')"
|
:placeholder="t('transaction.tagPlaceholder')"
|
||||||
style="flex: 1;"
|
style="flex: 1;"
|
||||||
/>
|
/>
|
||||||
<NButton v-if="aiEnabled" size="small" :loading="aiTagLoading" @click="handleAiTagSuggest">{{ t('ai.pickButton') }}</NButton>
|
<NButton v-if="aiEnabled" size="small" :loading="aiTagSuggestLoading" @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="aiTagNewLoading" @click="handleAiNewTag">{{ t('ai.newTag.button') }}</NButton>
|
||||||
</div>
|
</div>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<!-- AI 生成的新标签确认条:用户确认后才保存 -->
|
<!-- AI 生成的新标签确认条:用户确认后才保存 -->
|
||||||
@@ -1908,7 +1756,7 @@ onMounted(() => {
|
|||||||
<NSwitch v-model:value="form.includeInStats" />
|
<NSwitch v-model:value="form.includeInStats" />
|
||||||
<span style="color: #999; margin-left: 8px; font-size: 12px;">{{ t('transaction.includeInStatsHint') }}</span>
|
<span style="color: #999; margin-left: 8px; font-size: 12px;">{{ t('transaction.includeInStatsHint') }}</span>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NButton type="primary" block @click="handleSubmit">{{ t('common.confirm') }}</NButton>
|
<NButton type="primary" block :loading="submitting" @click="handleSubmit">{{ t('common.confirm') }}</NButton>
|
||||||
</NForm>
|
</NForm>
|
||||||
</NModal>
|
</NModal>
|
||||||
|
|
||||||
@@ -2005,6 +1853,43 @@ onMounted(() => {
|
|||||||
</NSpace>
|
</NSpace>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</NModal>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -328,7 +328,10 @@ public class StatisticsServiceImpl implements StatisticsService {
|
|||||||
Map<Long, Category> categoryMap, BigDecimal income, BigDecimal expense) {
|
Map<Long, Category> categoryMap, BigDecimal income, BigDecimal expense) {
|
||||||
switch (dimension) {
|
switch (dimension) {
|
||||||
case CATEGORY -> {
|
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.setName(category != null ? category.getName() : "未分类");
|
||||||
vo.setIcon(category != null ? category.getIcon() : "");
|
vo.setIcon(category != null ? category.getIcon() : "");
|
||||||
vo.setType(category != null ? category.getType()
|
vo.setType(category != null ? category.getType()
|
||||||
|
|||||||
Reference in New Issue
Block a user