Files
ledgerly/frontend/src/views/TransactionList.vue
T
rosercode 2ade60da42 feat: AI 图片记账、自定义交易渠道与淘宝/京东账单导入
- AI 图片记账:多图上传由视觉模型识别,SSE 实时推送进度,提取 rawData,按「类型+金额+时间」检测重复,支持缩略图与来源图片查看
- 自定义交易渠道:新增 t_channel 与渠道管理页,支持新建/改名/删除,并接入统计、导出与 AI 查询/记账/建议提示词
- AI 建议:单条建议新增交易渠道推荐(推荐已有渠道 / AI 新建渠道)
- 账单导入:新增淘宝(xlsx)、京东(CSV)解析器
- 交易:新增批量创建接口,导入时按去重键兜底跳过重复
- 运行目录统一经 AppHome 解析,图片与数据文件落盘规范化
- 前端:AI 记账方式偏好记忆、AI 按钮与输入框对齐、来源图片弹窗支持加载原图
2026-09-21 16:21:58 +08:00

2670 lines
98 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// 收支记录管理组件:展示交易记录列表(分页),支持新增、编辑、删除、多条件筛选
import { ref, onMounted, onBeforeUnmount, computed, h, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDivider, NDropdown, NUpload, NUploadDragger, NTabs, NTabPane, NSpin } from 'naive-ui'
import { EyeOutline, EyeOffOutline, SparklesOutline, BulbOutline, RefreshOutline, CloudUploadOutline, CheckmarkCircleOutline } from '@vicons/ionicons5'
import type { DataTableColumns, UploadFileInfo } from 'naive-ui'
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags, getChannels, batchDeleteTransactions, batchCreateTransactions } from '../api/transaction'
import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi, parseChannelByAi, getAiImageBlob } from '../api/ai'
import { createChannel } from '../api/channel'
import type { AiSuggestion, AiQueryResult, AiRecordResult, AiRecordImageResult } from '../api/ai'
import { getExcludedDisplayStyle } from '../api/setting'
import { getCategoryList, createCategory } from '../api/category'
import { getTagList, createTag } from '../api/tag'
import { getChannelLabel } from '../utils/channel'
import { renderCategoryLabel } from '../utils/render'
import { useKeywordHistory } from '../composables/useKeywordHistory'
import { openSse } from '../utils/sse'
import BillImportModal from './BillImportModal.vue'
const message = useMessage() // 消息提示
const dialog = useDialog() // 对话框
const router = useRouter() // 路由
const { t } = useI18n() // 国际化
/** 交易记录数据结构 */
interface TransactionItem {
id: number
categoryId: number
categoryName: string
categoryIcon: string
type: string // INCOME 或 EXPENSE
amount: number
channel: string
remark: string
remarkHidden: boolean
transactionTime: string
includeInStats: boolean
rawData: string | null
tags: TagOption[]
/** 来源图片ID列表(图片记账生成时存在) */
imageIds: number[]
createdAt: string
}
/** 分类选项数据结构 */
interface CategoryOption {
label: string
value: number
type: string
icon: string
}
/** 标签数据结构 */
interface TagOption {
id: number
name: string
color: string
}
/** 图片记账结果编辑行 */
interface AiRecordEditRow {
key: number
type: string
amount: number | null
categoryId: number | null
channel: string
remark: string
transactionTime: number
includeInStats: boolean
tagIds: number[]
/** 模型提取的原始字段 JSON 文本,导入时写入交易记录的原始数据 */
rawData: string | null
/** 来源图片序号(从 1 开始) */
imageIndex: number | null
/** 来源图片ID,导入时用于关联图片 */
imageId: number | null
/** 是否疑似重复(默认不勾选,可手动勾选强制导入) */
duplicate: boolean
}
// 列表数据和分页状态
const transactions = ref<TransactionItem[]>([]) // 交易记录列表
const total = ref(0) // 总记录数
const page = ref(1) // 当前页码
const pageSize = ref(20) // 每页条数
const sortValue = ref('transactionTime-desc') // 排序方式:排序字段-方向
const sortOptions = computed(() => [
{ label: t('transaction.sort.timeDesc'), value: 'transactionTime-desc' },
{ label: t('transaction.sort.timeAsc'), value: 'transactionTime-asc' },
{ label: t('transaction.sort.amountDesc'), value: 'amount-desc' },
{ label: t('transaction.sort.amountAsc'), value: 'amount-asc' },
])
const sortField = computed(() => sortValue.value.split('-')[0]) // 服务端排序字段
const sortOrder = computed(() => sortValue.value.split('-')[1]) // 服务端排序方向:asc/desc
const pageSizes = [10, 20, 50, 100, 200, 500] // 可选的每页条数
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value))) // 总页数
// 当前页金额汇总:支出、收入与结余(含本页所有展示记录)
const pageSummary = computed(() => {
let income = 0
let expense = 0
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 }
})
// 当前页不计入统计的金额汇总(仅统计 includeInStats=false 的记录)
const pageExcludedSummary = computed(() => {
let income = 0
let expense = 0
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) // 列表加载状态
// 「不计入统计」记录的展示样式:strikethrough-整行删除线(默认)、muted-灰色标签
const excludedDisplayStyle = ref<'strikethrough' | 'muted'>('strikethrough')
const submitting = ref(false) // 表单提交状态
const showModal = ref(false) // 新增/编辑弹窗显示状态
const showImportModal = ref(false) // 账单导入弹窗显示状态
const showBatchModal = ref(false) // 批量设置分类弹窗显示状态
const showAiModal = ref(false) // AI 建议弹窗显示状态
const aiEnabled = ref(false) // AI 建议是否启用
const aiLoading = ref(false) // AI 建议生成状态
const aiFormLoading = ref(false) // 编辑弹窗内 AI 推荐状态
/** AI 建议范围偏好存储键 */
const AI_SCOPE_KEY = 'finance.transaction.aiScope'
/** 读取上次的 AI 建议范围偏好,非法值回退 BOTH */
function loadAiScope(): string {
try {
const saved = localStorage.getItem(AI_SCOPE_KEY)
return saved && ['BOTH', 'CATEGORY', 'TAG'].includes(saved) ? saved : 'BOTH'
} catch (e) {
return 'BOTH'
}
}
const aiScope = ref(loadAiScope()) // 批量 AI 建议范围:BOTH/CATEGORY/TAG(记忆用户偏好)
// 批量 AI 建议范围选项
const aiScopeOptions = computed(() => [
{ label: t('ai.scope.both'), value: 'BOTH' },
{ label: t('ai.scope.category'), value: 'CATEGORY' },
{ label: t('ai.scope.tag'), value: 'TAG' },
])
// 记住用户选择的 AI 建议范围
watch(aiScope, (value) => {
try {
localStorage.setItem(AI_SCOPE_KEY, value)
} catch (e) {
// 忽略存储异常
}
})
const aiNewLoading = ref(false) // 编辑弹窗内 AI 生成新分类状态
const aiNewSaving = ref(false) // 新分类保存状态
// AI 生成但尚未保存的新分类,需用户确认后再落库
const pendingCategory = ref<{ name: string; type: string; icon: string } | null>(null)
const aiTagSuggestLoading = ref(false) // 编辑弹窗内「推荐已有标签」请求状态
const aiChannelLoading = ref(false) // 编辑弹窗内「推荐交易渠道」请求状态
const aiChannelNewLoading = ref(false) // 编辑弹窗内「生成新渠道」请求状态
const aiChannelNewSaving = ref(false) // 编辑弹窗内「保存新渠道」请求状态
const pendingChannel = ref<{ name: string } | null>(null) // AI 生成的新渠道(待用户确认保存)
const aiTagNewLoading = ref(false) // 编辑弹窗内「生成新标签」请求状态
const aiTagSaving = ref(false) // 新标签保存状态
// AI 生成但尚未保存的新标签,需用户确认后再落库
const pendingTag = ref<{ name: string; color: string } | null>(null)
const aiApplying = ref(false) // AI 建议应用状态
const aiSuggestions = ref<AiSuggestion[]>([]) // AI 建议列表
const checkedAiRowKeys = ref<number[]>([]) // AI 建议弹窗中已勾选的代表记录ID
const aiQueryText = ref('') // AI 自然语言查询输入
const aiQuerying = ref(false) // AI 查询解析状态
const aiQueryResult = ref<AiQueryResult | null>(null) // AI 查询解析结果
const showAiQueryModal = ref(false) // AI 智能查询弹窗显示状态
// AI 查询历史存储键
const AI_QUERY_HISTORY_KEY = 'finance.transaction.aiQueryHistory'
// 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 记账解析状态
/** AI 记账方式偏好存储键(参考邮箱页 Tab 记忆) */
const AI_RECORD_TAB_KEY = 'finance.transaction.aiRecordTab'
/** 读取上次的 AI 记账方式偏好,非法值回退文字记账 */
function loadAiRecordTab(): 'text' | 'image' {
try {
const saved = localStorage.getItem(AI_RECORD_TAB_KEY)
return saved === 'image' ? 'image' : 'text'
} catch (e) {
return 'text'
}
}
const aiRecordTab = ref<'text' | 'image'>(loadAiRecordTab()) // AI 记账方式:文字/图片(记忆用户偏好)
// 记住用户选择的 AI 记账方式
watch(aiRecordTab, (value) => {
try {
localStorage.setItem(AI_RECORD_TAB_KEY, value)
} catch (e) {
// 忽略存储异常
}
})
/** 图片记账单次最多上传的图片数量 */
const MAX_AI_RECORD_IMAGES = 5
const aiRecordFileList = ref<UploadFileInfo[]>([]) // 图片记账上传文件列表
const aiRecordFiles = ref<File[]>([]) // 待识别的图片文件(最多 MAX_AI_RECORD_IMAGES 张)
const aiRecordHint = ref('') // 图片识别补充说明
const aiRecordingImage = ref(false) // 图片识别状态
const showAiRecordResultModal = ref(false) // 图片记账结果弹窗显示状态
const aiRecordSummary = ref('') // 图片识别摘要
const aiRecordRows = ref<AiRecordEditRow[]>([]) // 图片识别结果行(可编辑)
const checkedAiRecordKeys = ref<number[]>([]) // 图片识别结果勾选的行
const aiRecordImporting = ref(false) // 图片记账导入状态
const aiRecordProgress = ref<{ time: string; text: string }[]>([]) // 图片识别进度日志(含接收时间)
let aiRecordStreamController: AbortController | null = null // 图片识别 SSE 连接控制器
const showImageViewModal = ref(false) // 来源图片查看弹窗显示状态
/** 来源图片查看项:默认加载缩略图,用户可按需切换为原图 */
interface ImageViewItem {
id: number // 图片ID
url: string // 当前展示的 objectURL
isOriginal: boolean // 是否已加载原图
loading: boolean // 原图加载状态
}
const imageViewItems = ref<ImageViewItem[]>([]) // 来源图片列表
const imageViewLoading = ref(false) // 来源图片加载状态
const showRawModal = ref(false) // 原始账单数据弹窗显示状态
const rawDataText = ref('') // 格式化后的原始账单数据文本
const batchExpenseCategoryId = ref<number | null>(null) // 批量设置的支出分类
const batchIncomeCategoryId = ref<number | null>(null) // 批量设置的收入分类
const batchIncludeInStats = ref<'null' | 'true' | 'false'>('null') // 批量设置的计入统计
const batchAddTagIds = ref<number[]>([]) // 批量添加的标签
const batchRemoveTagIds = ref<number[]>([]) // 批量移除的标签
const batching = ref(false) // 批量设置提交状态
// 当前表格是否包含收入/支出记录,用于按需展示批量分类项
const hasIncomeRecords = computed(() => transactions.value.some((tx) => tx.type === 'INCOME'))
const hasExpenseRecords = computed(() => transactions.value.some((tx) => tx.type === 'EXPENSE'))
// 批量设置计入统计选项
const batchStatsOptions = computed(() => [
{ label: t('transaction.batchStats.keep'), value: 'null' },
{ label: t('transaction.batchStats.include'), value: 'true' },
{ label: t('transaction.batchStats.exclude'), value: 'false' },
])
const editingId = ref<number | null>(null) // 当前编辑的记录 ID(null 表示新增)
const categories = ref<CategoryOption[]>([]) // 分类选项列表
const allTags = ref<TagOption[]>([]) // 全部标签(用于筛选、编辑、批量)
const revealedRemarkIds = ref<number[]>([]) // 临时展开查看原文的备注行 ID(不持久,刷新/翻页后重置)
const checkedTransactionIds = ref<number[]>([]) // 表格多选选中的记录ID(用于批量删除)
// 表单数据(新增/编辑共用)
const form = ref({
categoryId: null as number | null,
type: 'EXPENSE',
amount: null as number | null,
channel: '',
remark: '',
transactionTime: Date.now(),
includeInStats: true,
tagIds: [] as number[],
})
// 类型选项
const typeOptions = computed(() => [
{ label: t('common.types.expense'), value: 'EXPENSE' },
{ label: t('common.types.income'), value: 'INCOME' },
])
// 交易渠道编码列表(从后端动态获取,含账单导入来源与人工交易渠道)
const channelCodes = ref<string[]>([])
// 交易渠道选项:由后端返回的编码映射为当前语言标签
const channelOptions = computed(() =>
channelCodes.value.map((code) => ({ label: getChannelLabel(code), value: code })),
)
// 筛选条件状态
const filterType = ref<string[]>([]) // 类型筛选(多选)
const filterCategory = ref<number[]>([]) // 分类筛选(多选)
const filterChannel = 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 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[]>([]) // 标签筛选(多选)
const showMoreFilter = ref(false) // 更多筛选弹层显示状态
// 更多筛选中已激活的条件数量
const moreFilterCount = computed(() => {
let count = 0
if (filterTag.value.length > 0) count++
if (filterIncludeInStats.value !== null) count++
if (filterMinAmount.value !== null || filterMaxAmount.value !== null) count++
return count
})
/** 清空「更多筛选」中的条件 */
function clearMoreFilters() {
filterTag.value = []
filterIncludeInStats.value = null
filterMinAmount.value = null
filterMaxAmount.value = null
}
// 分类筛选项:固定包含「未分类」,其余分类按类型筛选联动
const categoryFilterOptions = computed(() => [
{ label: t('transaction.uncategorized'), value: 0, type: '' },
...categories.value.filter(c => filterType.value.length === 0 || filterType.value.includes(c.type)),
])
// 标签筛选项
const tagFilterOptions = computed(() => allTags.value.map((tag) => ({ label: tag.name, value: tag.id })))
// 标签筛选下拉项:额外提供「无标签」选项(仅筛选使用,编辑/批量选择不包含)
const tagFilterSelectOptions = computed(() => [
{ label: t('transaction.untagged'), value: 0 },
...tagFilterOptions.value,
])
/** 将标签筛选拆分为标签ID与无标签标记,供查询与导出复用 */
function buildTagFilter() {
const ids = filterTag.value.filter((v) => v !== 0)
return {
tagIds: ids.length > 0 ? ids : undefined,
untagged: filterTag.value.includes(0) ? true : undefined,
}
}
/** 将分类筛选拆分为分类ID与未分类标记,供查询与导出复用 */
function buildCategoryFilter() {
const ids = filterCategory.value.filter((v) => v !== 0)
return {
categoryIds: ids.length > 0 ? ids : undefined,
uncategorized: filterCategory.value.includes(0) ? true : undefined,
}
}
// 计入统计筛选下拉选项
const includeInStatsOptions = computed(() => [
{ label: t('transaction.includeFilter.yes'), value: 'true' },
{ label: t('transaction.includeFilter.no'), value: 'false' },
])
/** 筛选条件偏好存储键 */
const FILTER_PREFS_KEY = 'finance.transaction.filterPrefs'
/** 从 localStorage 读取上次的筛选条件偏好并还原 */
function loadFilterPrefs() {
try {
const raw = localStorage.getItem(FILTER_PREFS_KEY)
if (!raw) return
const p = JSON.parse(raw)
if (Array.isArray(p.filterType)) filterType.value = p.filterType
if (Array.isArray(p.filterCategory)) filterCategory.value = p.filterCategory
// 兼容旧版本以 filterPaymentMethod 存储的偏好
const savedChannel = Array.isArray(p.filterChannel) ? p.filterChannel : p.filterPaymentMethod
if (Array.isArray(savedChannel)) filterChannel.value = savedChannel
if (Array.isArray(p.filterDateRange) && p.filterDateRange.length === 2) filterDateRange.value = p.filterDateRange
if (p.filterIncludeInStats === 'true' || p.filterIncludeInStats === 'false') filterIncludeInStats.value = p.filterIncludeInStats
if (typeof p.filterRemark === 'string') filterRemark.value = p.filterRemark
if (typeof p.filterMinAmount === 'number') filterMinAmount.value = p.filterMinAmount
if (typeof p.filterMaxAmount === 'number') filterMaxAmount.value = p.filterMaxAmount
if (typeof p.pageSize === 'number' && pageSizes.includes(p.pageSize)) pageSize.value = p.pageSize
if (typeof p.sortValue === 'string' && sortOptions.value.some((o) => o.value === p.sortValue)) sortValue.value = p.sortValue
if (Array.isArray(p.filterTag)) filterTag.value = p.filterTag
} catch (e) {
// 忽略损坏的偏好数据
}
}
/** 将当前筛选条件写入 localStorage,下次进入自动还原 */
function saveFilterPrefs() {
try {
localStorage.setItem(FILTER_PREFS_KEY, JSON.stringify({
filterType: filterType.value,
filterCategory: filterCategory.value,
filterChannel: filterChannel.value,
filterDateRange: filterDateRange.value,
filterIncludeInStats: filterIncludeInStats.value,
filterRemark: filterRemark.value,
filterMinAmount: filterMinAmount.value,
filterMaxAmount: filterMaxAmount.value,
pageSize: pageSize.value,
sortValue: sortValue.value,
filterTag: filterTag.value,
}))
} catch (e) {
// 忽略写入失败
}
}
// 筛选条件变化时自动保存用户偏好
watch(
[filterType, filterCategory, filterChannel, filterDateRange, filterIncludeInStats, filterRemark, filterMinAmount, filterMaxAmount, pageSize, sortValue, filterTag],
saveFilterPrefs,
{ deep: true },
)
/** 选中历史项:填入并立即搜索 */
function handleRemarkSelect(value: string) {
if (value === remarkHistory.clearValue) {
remarkHistory.clear()
return
}
filterRemark.value = value
handleSearch()
}
/** 失焦时记录当前备注关键词 */
function handleRemarkBlur() {
remarkHistory.record()
}
/** 回车时记录关键词并搜索(阻止 NAutoComplete 内部选中建议项,保持原回车搜索语义) */
function handleRemarkKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.stopPropagation()
remarkHistory.record()
handleSearch()
}
}
/** 将时间戳格式化为 ISO 格式的日期时间字符串(YYYY-MM-DDTHH:mm:ss) */
function formatTimestamp(ts: number, isEnd = false): string {
const d = new Date(ts)
const pad = (n: number) => String(n).padStart(2, '0')
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const time = isEnd ? '23:59:59' : '00:00:00'
return `${date}T${time}`
}
/** 将时间戳格式化为本地日期时间字符串(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())}`
}
/** 分类已选项(多选标签)渲染:图标 + 名称 */
function renderCategoryTag({ option }: { option: any }) {
return h('span', null, [
option.icon ? h('span', { style: { marginRight: '4px' } }, option.icon) : null,
option.label,
])
}
/** 渲染备注单元格内的小图标按钮 */
function renderRemarkAction(label: string, icon: any, onClick: () => void) {
return h(NButton, {
size: 'tiny',
quaternary: true,
style: { marginLeft: '6px', flexShrink: 0 },
onClick,
}, {
icon: () => h(NIcon, { size: 14 }, { default: () => h(icon) }),
default: () => label,
})
}
/** 临时展开某行的备注原文(仅本格,不改变隐藏标记) */
function revealRemark(id: number) {
if (!revealedRemarkIds.value.includes(id)) {
revealedRemarkIds.value = [...revealedRemarkIds.value, id]
}
}
/** 收起某行临时展开的备注 */
function concealRemark(id: number) {
revealedRemarkIds.value = revealedRemarkIds.value.filter((v) => v !== id)
}
/** 持久切换某行备注的隐藏标记 */
async function setRemarkHidden(row: TransactionItem, hidden: boolean) {
try {
await toggleRemarkHidden(row.id, hidden)
row.remarkHidden = hidden
concealRemark(row.id)
message.success(hidden ? t('transaction.remarkHiddenSuccess') : t('transaction.remarkShownSuccess'))
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
}
}
/** 创建表格列配置 */
function createColumns(): DataTableColumns<TransactionItem> {
// 读取展示样式,使列配置随样式偏好变化而重新计算
const muted = excludedDisplayStyle.value === 'muted'
return [
// 多选列:用于批量操作(如批量删除)
{ type: 'selection' },
{
// 序号列:按分页计算全局序号
title: t('transaction.column.index'), key: 'index', width: 70, align: 'center',
render(_row, index) {
return (page.value - 1) * pageSize.value + index + 1
},
},
{
title: t('transaction.column.transactionTime'), key: 'transactionTime', width: 170,
render(row) {
const time = row.transactionTime?.replace('T', ' ').substring(0, 16) || '-'
const created = row.createdAt?.replace('T', ' ').substring(0, 16)
return h('span', { title: t('transaction.createdTime', { time: created || '-' }) }, time)
},
},
{
title: t('transaction.column.type'), key: 'type', width: 90,
render(row) {
// 灰色标签样式下,不计入统计的记录类型显示为「不计入」
if (muted && !row.includeInStats) {
return h(NTag, { type: 'default', size: 'small' }, { default: () => t('transaction.notIncluded') })
}
return h(NTag, { type: row.type === 'INCOME' ? 'success' : 'error', size: 'small' }, { default: () => row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense') })
},
},
{
title: t('transaction.column.category'), key: 'categoryName', width: 120,
render(row) {
const name = row.categoryName || t('transaction.uncategorized')
return row.categoryIcon ? `${row.categoryIcon} ${name}` : name
},
},
{
title: t('transaction.column.amount'), key: 'amount', width: 120,
render(row) {
// 灰色标签样式下,不计入统计的记录金额置灰且不显示正负号
if (muted && !row.includeInStats) {
return h('span', { style: { color: '#999', fontWeight: 'bold' } }, row.amount.toFixed(2))
}
return h('span', { style: { color: row.type === 'INCOME' ? '#18a058' : '#d03050', fontWeight: 'bold' } },
`${row.type === 'INCOME' ? '+' : '-'}${row.amount.toFixed(2)}`)
},
},
{
title: t('transaction.column.channel'), key: 'channel', width: 100,
render(row) { return getChannelLabel(row.channel) },
},
{
title: t('transaction.column.remark'), key: 'remark', minWidth: 220,
render(row) {
const hidden = !!row.remarkHidden
const collapsed = hidden && !revealedRemarkIds.value.includes(row.id)
const nodes: any[] = [
h('span', {
class: 'remark-text',
title: collapsed ? t('transaction.remarkHidden') : (row.remark || ''),
}, collapsed ? '******' : (row.remark || '-')),
]
if (collapsed) {
nodes.push(renderRemarkAction(t('transaction.remarkView'), EyeOutline, () => revealRemark(row.id)))
nodes.push(renderRemarkAction(t('transaction.remarkUnhide'), EyeOffOutline, () => setRemarkHidden(row, false)))
} else if (hidden) {
nodes.push(renderRemarkAction(t('transaction.remarkCollapse'), EyeOffOutline, () => concealRemark(row.id)))
nodes.push(renderRemarkAction(t('transaction.remarkUnhide'), EyeOutline, () => setRemarkHidden(row, false)))
} else {
nodes.push(renderRemarkAction(t('transaction.remarkHide'), EyeOffOutline, () => setRemarkHidden(row, true)))
}
return h('div', { class: 'remark-cell' }, nodes)
},
},
{
title: t('transaction.column.tags'), key: 'tags', minWidth: 140,
render(row) {
if (!row.tags || row.tags.length === 0) return '-'
return h(NSpace, { size: 4 }, {
default: () => row.tags.map((tag) =>
h(NTag, { size: 'small', color: { color: tag.color || '#2080f0', textColor: '#fff' } }, { default: () => tag.name })),
})
},
},
{
title: t('transaction.column.includeInStats'), key: 'includeInStats', width: 100, align: 'center',
render(row) {
return h(NSwitch, {
value: row.includeInStats,
onUpdateValue: (v: boolean) => handleToggleStats(row, v),
})
},
},
{
title: t('transaction.sourceImages'), key: 'imageIds', width: 110, align: 'center',
render(row) {
if (!row.imageIds || row.imageIds.length === 0) {
return h('span', { style: { color: '#ccc' } }, '-')
}
return h(NButton, { size: 'small', quaternary: true, type: 'primary', onClick: () => openImageView(row.imageIds) },
{ default: () => t('transaction.viewImages', { count: row.imageIds.length }) })
},
},
{
title: t('transaction.column.actions'), key: 'actions', width: 240,
render(row) {
return h(NSpace, null, {
default: () => [
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }),
h(NButton, { size: 'small', disabled: !row.rawData, onClick: () => handleViewRaw(row) }, { default: () => t('transaction.rawData') }),
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => t('common.delete') }),
],
})
},
},
]
}
const columns = computed(() => createColumns()) // 表格列配置(随语言切换响应式更新)
/** 从后端加载交易渠道列表(内置 + 自定义),供筛选与编辑表单使用 */
async function loadChannels() {
try {
const res: any = await getChannels()
channelCodes.value = (res.data || []).map((item: any) => item.code)
} catch (e) {
// 静默处理,交易渠道下拉保持为空
}
}
/** 跳转到交易渠道管理页 */
function goChannelManage() {
router.push('/channels')
}
/** 加载「不计入统计」记录展示样式偏好,失败时保持默认删除线 */
async function loadExcludedDisplayStyle() {
try {
const res: any = await getExcludedDisplayStyle()
if (res.data === 'muted' || res.data === 'strikethrough') {
excludedDisplayStyle.value = res.data
}
} catch (e) {
// 静默处理,保持默认样式
}
}
/** 加载分类列表 */
async function fetchCategories() {
try {
const res: any = await getCategoryList()
categories.value = res.data.map((c: any) => ({ label: c.name, value: c.id, type: c.type, icon: c.icon || '' }))
} catch (e) {
// 静默处理
}
}
/** 加载标签列表 */
async function fetchTags() {
try {
const res: any = await getTagList()
allTags.value = res.data || []
} catch (e) {
// 静默处理
}
}
/** 加载交易记录列表(分页),携带所有筛选条件 */
async function fetchData() {
loading.value = true
try {
const res: any = await getTransactionPage({
page: page.value,
size: pageSize.value,
types: filterType.value.length > 0 ? filterType.value : undefined,
...buildCategoryFilter(),
channels: filterChannel.value.length > 0 ? filterChannel.value : undefined,
startTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[0]) : undefined,
endTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[1], true) : undefined,
includeInStats: filterIncludeInStats.value === null ? undefined : filterIncludeInStats.value === 'true',
minAmount: filterMinAmount.value ?? undefined,
maxAmount: filterMaxAmount.value ?? undefined,
remark: filterRemark.value.trim() || undefined,
sortField: sortField.value,
sortOrder: sortOrder.value,
...buildTagFilter(),
})
transactions.value = res.data.records
total.value = res.data.total
revealedRemarkIds.value = []
checkedTransactionIds.value = []
} catch (e: any) {
message.error(e.message || t('common.loadFailed'))
} finally {
loading.value = false
}
}
// 排序方式变化时重置到第一页并重新查询
watch(sortValue, () => {
page.value = 1
fetchData()
})
/** 查看某条记录的原始账单数据(格式化为缩进 JSON) */
function handleViewRaw(row: TransactionItem) {
if (!row.rawData) {
message.warning(t('transaction.noRawData'))
return
}
try {
rawDataText.value = JSON.stringify(JSON.parse(row.rawData), null, 2)
} catch (e) {
rawDataText.value = row.rawData
}
showRawModal.value = true
}
/** 查看图片记账结果行的原始数据(格式化为缩进 JSON) */
function handleViewAiRecordRaw(row: AiRecordEditRow) {
if (!row.rawData) return
try {
rawDataText.value = JSON.stringify(JSON.parse(row.rawData), null, 2)
} catch (e) {
rawDataText.value = row.rawData
}
showRawModal.value = true
}
/** 从接口响应中提取图片 Blob(blob 响应经拦截器后返回完整 axios response,需取其 data) */
function extractBlob(res: any): Blob {
return res?.data instanceof Blob ? res.data : (res instanceof Blob ? res : new Blob([res]))
}
/** 查看交易记录的来源图片(默认加载缩略图,避免大图拖慢打开速度) */
async function openImageView(imageIds: number[]) {
if (!imageIds || imageIds.length === 0) return
closeImageView()
showImageViewModal.value = true
imageViewLoading.value = true
try {
const items: ImageViewItem[] = []
for (const id of imageIds) {
const res: any = await getAiImageBlob(id, true)
items.push({ id, url: URL.createObjectURL(extractBlob(res)), isOriginal: false, loading: false })
}
imageViewItems.value = items
} catch (e: any) {
message.error(e.message || t('common.loadFailed'))
} finally {
imageViewLoading.value = false
}
}
/** 将指定来源图片切换为原图(替换并释放旧缩略图 objectURL) */
async function loadOriginalImage(item: ImageViewItem) {
if (item.isOriginal || item.loading) return
item.loading = true
try {
const res: any = await getAiImageBlob(item.id)
const url = URL.createObjectURL(extractBlob(res))
URL.revokeObjectURL(item.url)
item.url = url
item.isOriginal = true
} catch (e: any) {
message.error(e.message || t('common.loadFailed'))
} finally {
item.loading = false
}
}
/** 关闭来源图片弹窗并释放 objectURL */
function closeImageView() {
for (const item of imageViewItems.value) {
URL.revokeObjectURL(item.url)
}
imageViewItems.value = []
showImageViewModal.value = false
}
/** 打开新增记录弹窗,重置表单 */
function handleAdd() {
editingId.value = null
form.value = { categoryId: null, type: 'EXPENSE', amount: null, channel: '', remark: '', transactionTime: Date.now(), includeInStats: true, tagIds: [] }
pendingCategory.value = null
pendingTag.value = null
pendingChannel.value = null
showModal.value = true
}
/** 打开编辑记录弹窗,填充表单数据 */
function handleEdit(row: TransactionItem) {
editingId.value = row.id
form.value = {
categoryId: row.categoryId,
type: row.type,
amount: row.amount,
channel: row.channel || '',
remark: row.remark || '',
transactionTime: new Date(row.transactionTime).getTime(),
includeInStats: row.includeInStats ?? true,
tagIds: (row.tags || []).map((tag) => tag.id),
}
pendingCategory.value = null
pendingTag.value = null
pendingChannel.value = null
showModal.value = true
}
/** 提交表单:校验 -> 新增或更新 -> 刷新列表 */
async function handleSubmit() {
// 编辑已有记录时允许不设置分类,便于只修改标签等信息
if (!form.value.categoryId && !editingId.value) {
message.warning(t('transaction.selectCategory'))
return
}
if (!form.value.amount || form.value.amount <= 0) {
message.warning(t('transaction.validAmount'))
return
}
const data = {
categoryId: form.value.categoryId!,
type: form.value.type,
amount: form.value.amount!,
channel: form.value.channel || undefined,
remark: form.value.remark || undefined,
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)
message.success(t('common.updateSuccess'))
} else {
await createTransaction(data)
message.success(t('common.createSuccess'))
}
showModal.value = false
await fetchData()
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
submitting.value = false
}
}
/** 删除记录:弹出确认对话框,确认后执行删除并刷新列表 */
function handleDelete(id: number) {
dialog.warning({
title: t('common.confirmDelete'),
content: t('transaction.deleteConfirm'),
positiveText: t('common.delete'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
try {
await deleteTransaction(id)
message.success(t('common.deleteSuccess'))
await fetchData()
} catch (e: any) {
message.error(e.message || t('common.deleteFailed'))
}
},
})
}
/** 批量删除选中的记录:确认后调用接口并刷新列表 */
function handleBatchDelete() {
const count = checkedTransactionIds.value.length
if (count === 0) {
message.warning(t('transaction.batchDeleteNone'))
return
}
dialog.warning({
title: t('common.confirmDelete'),
content: t('transaction.batchDeleteConfirm', { count }),
positiveText: t('common.delete'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
try {
const res: any = await batchDeleteTransactions(checkedTransactionIds.value)
message.success(t('transaction.batchDeleted', { count: res.data }))
checkedTransactionIds.value = []
await fetchData()
} catch (e: any) {
message.error(e.message || t('common.deleteFailed'))
}
},
})
}
/** 翻页事件处理 */
function handlePageChange(p: number) {
page.value = p
fetchData()
}
/** 每页条数变化处理:重置到第一页并重新加载 */
function handlePageSizeChange(size: number) {
pageSize.value = size
page.value = 1
fetchData()
}
/** 执行查询:重置页码并按当前筛选条件加载数据 */
function handleSearch() {
page.value = 1
fetchData()
}
/** 重置所有筛选条件并重新查询 */
function handleReset() {
filterType.value = []
filterCategory.value = []
filterChannel.value = []
filterDateRange.value = null
filterIncludeInStats.value = null
filterRemark.value = ''
filterMinAmount.value = null
filterMaxAmount.value = null
filterTag.value = []
page.value = 1
fetchData()
}
/** 在表格中直接切换"计入统计"开关 */
async function handleToggleStats(row: TransactionItem, value: boolean) {
try {
await toggleTransactionStats(row.id, value)
row.includeInStats = value
message.success(value ? t('transaction.statsIncluded') : t('transaction.statsExcluded'))
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
}
}
/** 加载 AI 启用状态,用于控制批量设置中的入口显示 */
async function loadAiConfig() {
try {
const res: any = await getAiConfig()
aiEnabled.value = !!res.data.enabled
} catch (e) {
// 静默处理,AI 入口保持隐藏
aiEnabled.value = false
}
}
/** 请求 AI 建议:对当前表格展示的记录生成分类与标签建议 */
async function handleAiSuggest() {
if (transactions.value.length === 0) {
message.warning(t('transaction.noRecordsToSet'))
return
}
aiLoading.value = true
try {
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) {
message.warning(t('ai.noSuggestion'))
return
}
// 默认全选,用户可取消不需要的建议
checkedAiRowKeys.value = aiSuggestions.value.map((s) => s.transactionId)
message.success(t('ai.aggregated', { groups: aiSuggestions.value.length, count: aiTotalCount.value }))
showBatchModal.value = false
showAiModal.value = true
} catch (e: any) {
message.error(e.message || t('ai.suggestFailed'))
} finally {
aiLoading.value = false
}
}
/** 编辑弹窗内 AI 推荐分类:基于表单当前内容(类型、金额、交易渠道、备注) */
async function handleAiSuggestOne() {
aiFormLoading.value = true
try {
const res: any = await suggestOneByAi({
type: form.value.type,
amount: form.value.amount,
channel: form.value.channel || undefined,
remark: form.value.remark || undefined,
})
const suggestion = res.data
if (!suggestion || suggestion.categoryId == null) {
message.warning(t('ai.noSuggestion'))
return
}
form.value.categoryId = suggestion.categoryId
message.success(t('ai.oneApplied'))
} catch (e: any) {
message.error(e.message || t('ai.suggestFailed'))
} finally {
aiFormLoading.value = false
}
}
/** 编辑弹窗内根据账单信息生成新分类(暂不保存,等待用户确认) */
async function handleAiNewCategory() {
if (!form.value.remark && !form.value.amount) {
message.warning(t('ai.newCategory.needInfo'))
return
}
aiNewLoading.value = true
try {
const res: any = await parseCategoryByAi(buildCategoryPrompt())
const data = res.data
if (!data || !data.name) {
message.warning(t('ai.category.failed'))
return
}
// 类型跟随当前记录,避免与所选收支类型不一致
pendingCategory.value = { name: data.name, type: form.value.type, icon: data.icon || '' }
} catch (e: any) {
message.error(e.message || t('ai.category.failed'))
} finally {
aiNewLoading.value = false
}
}
/** 拼接用于生成分类的账单描述 */
function buildCategoryPrompt() {
const typeLabel = form.value.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')
const parts = [`这是一笔${typeLabel}记录`]
if (form.value.remark) parts.push(`备注:${form.value.remark}`)
if (form.value.amount) parts.push(`金额:${form.value.amount}`)
if (form.value.channel) parts.push(`交易渠道:${getChannelLabel(form.value.channel)}`)
parts.push('请生成一个能涵盖这类记录的记账分类')
return parts.join(',')
}
/** 确认保存 AI 生成的新分类并选用 */
async function handleSaveNewCategory() {
const pending = pendingCategory.value
if (!pending) {
return
}
// 已存在同名同类型分类时直接选用,避免重复创建
const existing = categories.value.find((c) => c.label === pending.name && c.type === pending.type)
if (existing) {
form.value.categoryId = existing.value
message.info(t('ai.newCategory.exists'))
pendingCategory.value = null
return
}
aiNewSaving.value = true
try {
await createCategory({ name: pending.name, type: pending.type, icon: pending.icon, sortOrder: 0 })
await fetchCategories()
// 排序值相同,取同名同类型中的最后一个(最新创建)
const created = [...categories.value].reverse().find((c) => c.label === pending.name && c.type === pending.type)
if (created) {
form.value.categoryId = created.value
}
message.success(t('ai.newCategory.saved'))
pendingCategory.value = null
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
aiNewSaving.value = false
}
}
/** 编辑弹窗内 AI 推荐已有标签:基于表单当前内容,追加到已选标签 */
async function handleAiTagSuggest() {
aiTagSuggestLoading.value = true
try {
const res: any = await suggestOneByAi({
type: form.value.type,
amount: form.value.amount,
channel: form.value.channel || undefined,
remark: form.value.remark || undefined,
})
const suggestion = res.data
if (!suggestion || !suggestion.tagIds || suggestion.tagIds.length === 0) {
message.warning(t('ai.noSuggestion'))
return
}
// 合并去重,保留已选标签
form.value.tagIds = Array.from(new Set([...form.value.tagIds, ...suggestion.tagIds]))
message.success(t('ai.tagApplied'))
} catch (e: any) {
message.error(e.message || t('ai.suggestFailed'))
} finally {
aiTagSuggestLoading.value = false
}
}
/** 编辑弹窗内 AI 推荐交易渠道:基于表单当前内容,仅填充渠道,不覆盖分类与标签 */
async function handleAiChannelSuggest() {
aiChannelLoading.value = true
try {
const res: any = await suggestOneByAi({
type: form.value.type,
amount: form.value.amount,
channel: form.value.channel || undefined,
remark: form.value.remark || undefined,
})
const suggestion = res.data
if (!suggestion || !suggestion.channel) {
message.warning(t('ai.noChannelSuggestion'))
return
}
form.value.channel = suggestion.channel
message.success(t('ai.channelApplied'))
} catch (e: any) {
message.error(e.message || t('ai.suggestFailed'))
} finally {
aiChannelLoading.value = false
}
}
/** 拼接用于生成渠道的账单描述 */
function buildChannelPrompt() {
const typeLabel = form.value.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')
const parts = [`这是一笔${typeLabel}记录`]
if (form.value.remark) parts.push(`备注:${form.value.remark}`)
if (form.value.amount) parts.push(`金额:${form.value.amount}`)
if (form.value.channel) parts.push(`当前渠道:${getChannelLabel(form.value.channel)}`)
parts.push('请生成一个适合这类交易的记账渠道名称')
return parts.join(',')
}
/** 编辑弹窗内根据账单信息生成新渠道(暂不保存,等待用户确认) */
async function handleAiNewChannel() {
if (!form.value.remark && !form.value.amount) {
message.warning(t('ai.newChannel.needInfo'))
return
}
aiChannelNewLoading.value = true
try {
const res: any = await parseChannelByAi(buildChannelPrompt())
const data = res.data
if (!data || !data.name) {
message.warning(t('ai.newChannel.failed'))
return
}
pendingChannel.value = { name: data.name }
} catch (e: any) {
message.error(e.message || t('ai.newChannel.failed'))
} finally {
aiChannelNewLoading.value = false
}
}
/** 确认保存 AI 生成的新渠道并选用 */
async function handleSaveNewChannel() {
const pending = pendingChannel.value
if (!pending) {
return
}
// 已存在同名渠道时直接选用,避免重复创建
const existing = channelOptions.value.find((option) => option.label === pending.name)
if (existing) {
form.value.channel = existing.value
message.info(t('ai.newChannel.exists'))
pendingChannel.value = null
return
}
aiChannelNewSaving.value = true
try {
const res: any = await createChannel(pending.name)
await loadChannels()
const created = res?.data
if (created?.code) {
form.value.channel = created.code
}
message.success(t('ai.newChannel.saved'))
pendingChannel.value = null
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
aiChannelNewSaving.value = false
}
}
/** 编辑弹窗内根据账单信息生成新标签(暂不保存,等待用户确认) */
async function handleAiNewTag() {
if (!form.value.remark && !form.value.amount) {
message.warning(t('ai.newTag.needInfo'))
return
}
aiTagNewLoading.value = true
try {
const res: any = await parseTagByAi(buildCategoryPrompt())
const data = res.data
if (!data || !data.name) {
message.warning(t('ai.newTag.failed'))
return
}
pendingTag.value = { name: data.name, color: data.color || '#2080f0' }
} catch (e: any) {
message.error(e.message || t('ai.newTag.failed'))
} finally {
aiTagNewLoading.value = false
}
}
/** 确认保存 AI 生成的新标签并选用 */
async function handleSaveNewTag() {
const pending = pendingTag.value
if (!pending) {
return
}
// 已存在同名标签时直接选用,避免重复创建
const existing = allTags.value.find((tag) => tag.name === pending.name)
if (existing) {
addTagToForm(existing.id)
message.info(t('ai.newTag.exists'))
pendingTag.value = null
return
}
aiTagSaving.value = true
try {
await createTag({ name: pending.name, color: pending.color, sortOrder: 0 })
await fetchTags()
// 排序值相同,取同名中的最后一个(最新创建)
const created = [...allTags.value].reverse().find((tag) => tag.name === pending.name)
if (created) {
addTagToForm(created.id)
}
message.success(t('ai.newTag.saved'))
pendingTag.value = null
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
aiTagSaving.value = false
}
}
/** 将标签追加到表单已选标签中(去重) */
function addTagToForm(tagId: number) {
if (!form.value.tagIds.includes(tagId)) {
form.value.tagIds = [...form.value.tagIds, tagId]
}
}
/** 一键应用全部 AI 建议:覆盖分类并追加标签 */
async function handleAiApply() {
const selected = new Set(checkedAiRowKeys.value)
const chosen = aiSuggestions.value.filter((s) => selected.has(s.transactionId))
if (chosen.length === 0) {
message.warning(t('ai.applyNone'))
return
}
// 勾选中包含新建分类/标签时,先经用户确认再落库
if (aiNewCategoryNames.value.length > 0 || aiNewTagNames.value.length > 0) {
const confirmed = await new Promise<boolean>((resolve) => {
dialog.warning({
title: t('ai.applyNewTitle'),
content: t('ai.applyNewConfirm', {
categories: aiNewCategoryNames.value.length,
tags: aiNewTagNames.value.length,
}),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
onPositiveClick: () => resolve(true),
onNegativeClick: () => resolve(false),
onClose: () => resolve(false),
})
})
if (!confirmed) {
return
}
}
aiApplying.value = true
try {
// 仅提交勾选的建议,聚合建议按组展开为逐条记录
const payload = chosen.flatMap((s) => {
const ids = s.transactionIds?.length ? s.transactionIds : [s.transactionId]
return ids.map((id) => ({
transactionId: id,
categoryId: s.categoryId,
newCategoryName: s.newCategoryName,
newCategoryIcon: s.newCategoryIcon,
tagIds: s.tagIds || [],
newTags: s.newTags || [],
}))
})
const res: any = await applyAiSuggestions(payload)
message.success(t('ai.applySuccess', { count: res.data }))
showAiModal.value = false
await fetchData()
// 应用过程中可能新建了分类或标签,刷新以保持一致
await fetchCategories()
await fetchTags()
} catch (e: any) {
message.error(e.message || t('ai.applyFailed'))
} finally {
aiApplying.value = false
}
}
/** 打开 AI 记账弹窗 */
function handleAiRecordOpen() {
if (!aiEnabled.value) {
message.warning(t('ai.needConfig'))
return
}
abortAiRecordStream()
aiRecordText.value = ''
// 沿用上次的记账方式偏好(文字/图片),不强制重置
aiRecordFileList.value = []
aiRecordFiles.value = []
aiRecordHint.value = ''
aiRecordProgress.value = []
showAiRecordModal.value = true
}
/** 解析自然语言记账并预填新增表单 */
async function handleAiRecordParse() {
const text = aiRecordText.value.trim()
if (!text) {
message.warning(t('ai.record.emptyInput'))
return
}
aiRecording.value = true
try {
const res: any = await parseRecordByAi(text)
const r: AiRecordResult = res.data
editingId.value = null
form.value = {
categoryId: r.categoryId ?? null,
type: r.type || 'EXPENSE',
amount: r.amount ?? null,
channel: r.channel || '',
remark: r.remark || '',
transactionTime: r.transactionTime ? new Date(r.transactionTime).getTime() : Date.now(),
includeInStats: r.includeInStats ?? true,
tagIds: r.tagIds || [],
}
showAiRecordModal.value = false
showModal.value = true
if (r.summary) {
message.info(r.summary)
}
} catch (e: any) {
message.error(e.message || t('ai.record.failed'))
} finally {
aiRecording.value = false
}
}
/** 图片记账:设置待识别的图片文件列表(最多 MAX_AI_RECORD_IMAGES 张) */
function setAiRecordFiles(files: File[]) {
const limited = files.slice(0, MAX_AI_RECORD_IMAGES)
aiRecordFiles.value = limited
aiRecordFileList.value = limited.map((file, index) => ({
id: `${Date.now()}-${index}`,
name: file.name || `clipboard-${index + 1}.png`,
status: 'finished',
file,
} as UploadFileInfo))
}
/** 图片记账:处理上传文件变化,最多保留 MAX_AI_RECORD_IMAGES 张 */
function handleAiRecordFileChange(options: { fileList: UploadFileInfo[] }) {
const files = options.fileList
.map((item) => item.file)
.filter((file): file is File => !!file)
setAiRecordFiles(files)
}
/** 图片记账:追加一张图片(用于多次粘贴累加) */
function appendAiRecordFile(file: File) {
if (aiRecordFiles.value.length >= MAX_AI_RECORD_IMAGES) {
message.warning(t('ai.record.maxImages', { count: MAX_AI_RECORD_IMAGES }))
return
}
setAiRecordFiles([...aiRecordFiles.value, file])
}
/** 图片记账:监听粘贴事件,从剪贴板读取图片(Ctrl+V / ⌘V),支持多次粘贴累加 */
function handleAiRecordPaste(e: ClipboardEvent) {
// 仅在图片记账弹窗打开时接收粘贴
if (!showAiRecordModal.value) return
const dt = e.clipboardData
if (!dt) return
for (let i = 0; i < dt.items.length; i++) {
const item = dt.items[i]
if (item.kind === 'file' && item.type.startsWith('image/')) {
const file = item.getAsFile()
if (file) {
e.preventDefault()
appendAiRecordFile(file)
aiRecordTab.value = 'image'
message.success(t('ai.record.pasteSuccess'))
}
return
}
}
}
/** 图片记账:应用识别结果并打开结果预览 */
function applyAiRecordImageResult(result: AiRecordImageResult) {
const records = result.records || []
if (records.length === 0) {
message.warning(t('ai.record.noRecord'))
return
}
aiRecordSummary.value = result.summary || ''
aiRecordRows.value = records.map((r, index) => ({
key: index,
type: r.type || 'EXPENSE',
amount: r.amount ?? null,
categoryId: r.categoryId ?? null,
channel: r.channel || '',
remark: r.remark || '',
transactionTime: r.transactionTime ? new Date(r.transactionTime).getTime() : Date.now(),
includeInStats: r.includeInStats ?? true,
tagIds: r.tagIds || [],
rawData: r.rawData ?? null,
imageIndex: r.imageIndex ?? null,
imageId: r.imageId ?? null,
duplicate: r.duplicate ?? false,
}))
// 疑似重复的记录默认不勾选,用户可手动勾选强制导入
checkedAiRecordKeys.value = aiRecordRows.value.filter((row) => !row.duplicate).map((row) => row.key)
showAiRecordModal.value = false
showAiRecordResultModal.value = true
}
/** 图片记账:中断进行中的识别连接 */
function abortAiRecordStream() {
if (aiRecordStreamController) {
aiRecordStreamController.abort()
aiRecordStreamController = null
}
}
/** 将时间格式化为 HH:mm:ss,用于进度日志前缀 */
function formatProgressTime(date: Date): string {
const pad = (value: number) => String(value).padStart(2, '0')
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
/** 图片记账:通过 SSE 调用视觉模型识别,实时展示进度 */
function handleAiRecordParseImage() {
if (aiRecordFiles.value.length === 0) {
message.warning(t('ai.record.emptyInput'))
return
}
abortAiRecordStream()
aiRecordProgress.value = []
aiRecordingImage.value = true
const formData = new FormData()
for (const file of aiRecordFiles.value) {
formData.append('files', file)
}
const hint = aiRecordHint.value.trim()
if (hint) formData.append('hint', hint)
aiRecordStreamController = openSse('/api/ai/record/image/stream', {
method: 'POST',
body: formData,
onEvent: (event, data) => {
if (event === 'progress') {
aiRecordProgress.value.push({ time: formatProgressTime(new Date()), text: data })
} else if (event === 'done') {
try {
applyAiRecordImageResult(JSON.parse(data) as AiRecordImageResult)
} catch (e) {
message.error(t('ai.record.imageFailed'))
}
} else if (event === 'failed') {
let reason = ''
try {
reason = JSON.parse(data)?.message || ''
} catch (e) {
reason = data
}
aiRecordProgress.value.push({
time: formatProgressTime(new Date()),
text: reason ? `${t('ai.record.imageFailed')}:${reason}` : t('ai.record.imageFailed'),
})
message.error(reason || t('ai.record.imageFailed'))
}
},
onError: (e: any) => {
message.error(e?.message || t('ai.record.imageFailed'))
},
onClose: () => {
aiRecordingImage.value = false
aiRecordStreamController = null
},
})
}
/** 关闭 AI 记账弹窗,中断进行中的识别 */
function handleAiRecordCancel() {
abortAiRecordStream()
aiRecordingImage.value = false
showAiRecordModal.value = false
}
/** 图片记账:导入勾选的结果行 */
async function handleAiRecordImport() {
const selected = aiRecordRows.value.filter((row) => checkedAiRecordKeys.value.includes(row.key))
if (selected.length === 0) {
message.warning(t('ai.record.selectAtLeastOne'))
return
}
if (selected.some((row) => !row.amount || row.amount <= 0)) {
message.warning(t('transaction.validAmount'))
return
}
aiRecordImporting.value = true
try {
const res: any = await batchCreateTransactions({
records: selected.map((row) => ({
categoryId: row.categoryId,
type: row.type,
amount: row.amount!,
channel: row.channel || undefined,
remark: row.remark || undefined,
transactionTime: formatLocalDateTime(row.transactionTime),
includeInStats: row.includeInStats,
tagIds: row.tagIds,
rawData: row.rawData || undefined,
imageId: row.imageId ?? undefined,
})),
})
message.success(t('ai.record.importSuccess', { count: res.data ?? selected.length }))
showAiRecordResultModal.value = false
await fetchData()
} catch (e: any) {
message.error(e.message || t('ai.record.importFailed'))
} finally {
aiRecordImporting.value = false
}
}
/** 图片记账结果表格列(类型、金额、分类、时间、备注可编辑) */
const aiRecordColumns = computed<DataTableColumns<AiRecordEditRow>>(() => [
{ type: 'selection' },
{
title: t('ai.record.col.source'),
key: 'imageIndex',
width: 80,
render: (row) => (row.imageIndex ? t('ai.record.sourceImageLabel', { index: row.imageIndex }) : '-'),
},
{
title: t('ai.record.col.duplicate'),
key: 'duplicate',
width: 90,
render: (row) => (row.duplicate
? h(NTag, { type: 'warning', size: 'small' }, { default: () => t('ai.record.duplicate') })
: '-'),
},
{
title: t('ai.record.col.type'),
key: 'type',
width: 100,
render: (row) => h(NSelect, {
value: row.type,
options: typeOptions.value,
size: 'small',
onUpdateValue: (value: string) => { row.type = value; row.categoryId = null },
}),
},
{
title: t('ai.record.col.amount'),
key: 'amount',
width: 120,
render: (row) => h(NInputNumber, {
value: row.amount,
min: 0.01,
precision: 2,
size: 'small',
onUpdateValue: (value: number | null) => { row.amount = value },
}),
},
{
title: t('ai.record.col.category'),
key: 'categoryId',
width: 170,
render: (row) => h(NSelect, {
value: row.categoryId,
options: categories.value.filter((c) => c.type === row.type),
renderLabel: renderCategoryLabel,
filterable: true,
clearable: true,
size: 'small',
onUpdateValue: (value: number | null) => { row.categoryId = value },
}),
},
{
title: t('ai.record.col.time'),
key: 'transactionTime',
width: 180,
render: (row) => h(NDatePicker, {
value: row.transactionTime,
type: 'datetime',
size: 'small',
onUpdateValue: (value: number | null) => { row.transactionTime = value ?? row.transactionTime },
}),
},
{
title: t('ai.record.col.remark'),
key: 'remark',
render: (row) => h(NInput, {
value: row.remark,
size: 'small',
onUpdateValue: (value: string) => { row.remark = value },
}),
},
{
title: t('ai.record.col.rawData'),
key: 'rawData',
width: 110,
render: (row) => (row.rawData
? h(NButton, { size: 'small', quaternary: true, type: 'primary', onClick: () => handleViewAiRecordRaw(row) },
{ default: () => t('transaction.rawData') })
: '-'),
},
])
/** 打开 AI 智能查询弹窗 */
function handleAiQueryOpen() {
if (!aiEnabled.value) {
message.warning(t('ai.needConfig'))
return
}
showAiQueryModal.value = true
}
/** 请求 AI 解析自然语言查询 */
async function handleAiQuery() {
if (!aiEnabled.value) {
message.warning(t('ai.needConfig'))
return
}
const text = aiQueryText.value.trim()
if (!text) {
message.warning(t('ai.query.emptyInput'))
return
}
aiQueryHistory.record()
aiQuerying.value = true
try {
const res: any = await queryByAi(text)
aiQueryResult.value = res.data
// 无任何可用筛选条件时提示,避免误清空当前筛选
if (aiQueryEmpty.value) {
message.warning(t('ai.query.noCondition'))
return
}
// 直接应用解析结果并搜索,无需二次确认
applyAiQuery()
showAiQueryModal.value = false
} catch (e: any) {
message.error(e.message || t('ai.query.failed'))
} finally {
aiQuerying.value = false
}
}
/** 将 AI 解析结果应用到筛选条件并查询 */
function applyAiQuery() {
const r = aiQueryResult.value
if (!r) return
filterType.value = r.types || []
const categoryIds = [...(r.categoryIds || [])]
if (r.uncategorized) categoryIds.push(0)
filterCategory.value = categoryIds
filterChannel.value = r.channels || []
filterDateRange.value = r.startTime && r.endTime
? [new Date(r.startTime).getTime(), new Date(r.endTime).getTime()]
: null
filterIncludeInStats.value = r.includeInStats == null ? null : (r.includeInStats ? 'true' : 'false')
filterMinAmount.value = r.minAmount ?? null
filterMaxAmount.value = r.maxAmount ?? null
filterRemark.value = r.remark || ''
filterTag.value = r.tagIds || []
handleSearch()
// 成功提示优先展示 AI 识别的查询意图
message.success(r.summary || t('ai.query.applied'))
}
/** 解析结果是否为空(无任何可用筛选条件) */
const aiQueryEmpty = computed(() => {
const r = aiQueryResult.value
if (!r) return true
return (r.types?.length ?? 0) === 0
&& (r.categoryIds?.length ?? 0) === 0
&& !r.uncategorized
&& (r.tagIds?.length ?? 0) === 0
&& (r.channels?.length ?? 0) === 0
&& !r.startTime
&& r.minAmount == null
&& r.maxAmount == null
&& !r.remark
&& r.includeInStats == null
})
/** AI 查询示例(从 i18n 拆分为数组) */
const queryExamples = computed(() => t('ai.query.examples').split('|'))
/** AI 查询示例下拉选项 */
const exampleOptions = computed(() => queryExamples.value.map((example) => ({ label: example, key: example })))
/** 选择示例后填入输入框 */
function handleExampleSelect(key: string) {
aiQueryText.value = key
}
/** 选中历史项:填入并立即发起查询 */
function handleAiQuerySelect(value: string) {
if (value === aiQueryHistory.clearValue) {
aiQueryHistory.clear()
return
}
aiQueryText.value = value
handleAiQuery()
}
/** 回车时发起查询(阻止 NAutoComplete 内部选中建议项) */
function handleAiQueryKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.stopPropagation()
handleAiQuery()
}
}
/** 将时间戳格式化为 yyyy-MM-dd */
function formatDateLabel(ts: number) {
const d = new Date(ts)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
/** 已激活的筛选条件标签(可单独移除) */
const activeFilterTags = computed(() => {
const tags: { key: string; label: string; clear: () => void }[] = []
filterType.value.forEach((v) => {
tags.push({
key: `type:${v}`,
label: v === 'INCOME' ? t('common.types.income') : t('common.types.expense'),
clear: () => { filterType.value = filterType.value.filter((x) => x !== v) },
})
})
filterCategory.value.forEach((id) => {
const name = id === 0
? t('transaction.uncategorized')
: categories.value.find((c) => c.value === id)?.label || String(id)
tags.push({
key: `category:${id}`,
label: name,
clear: () => { filterCategory.value = filterCategory.value.filter((x) => x !== id) },
})
})
if (filterDateRange.value) {
tags.push({
key: 'date',
label: `${formatDateLabel(filterDateRange.value[0])} ~ ${formatDateLabel(filterDateRange.value[1])}`,
clear: () => { filterDateRange.value = null },
})
}
if (filterRemark.value.trim()) {
tags.push({
key: 'remark',
label: `${t('transaction.column.remark')}: ${filterRemark.value.trim()}`,
clear: () => { filterRemark.value = '' },
})
}
filterChannel.value.forEach((v) => {
tags.push({
key: `channel:`,
label: getChannelLabel(v),
clear: () => { filterChannel.value = filterChannel.value.filter((x) => x !== v) },
})
})
filterTag.value.forEach((id) => {
const name = id === 0
? t('transaction.untagged')
: (allTags.value.find((tag) => tag.id === id)?.name || String(id))
tags.push({
key: `tag:${id}`,
label: name,
clear: () => { filterTag.value = filterTag.value.filter((x) => x !== id) },
})
})
if (filterIncludeInStats.value !== null) {
tags.push({
key: 'includeInStats',
label: filterIncludeInStats.value === 'true' ? t('transaction.includeFilter.yes') : t('transaction.includeFilter.no'),
clear: () => { filterIncludeInStats.value = null },
})
}
if (filterMinAmount.value != null || filterMaxAmount.value != null) {
tags.push({
key: 'amount',
label: `${t('transaction.amountRange')}: ${filterMinAmount.value ?? 0} ~ ${filterMaxAmount.value ?? '∞'}`,
clear: () => { filterMinAmount.value = null; filterMaxAmount.value = null },
})
}
return tags
})
/** 移除单个筛选条件后自动重查 */
function applyTagClear(tag: { clear: () => void }) {
tag.clear()
handleSearch()
}
/** AI 建议行:合并代表交易记录信息用于展示 */
const aiRows = computed(() =>
aiSuggestions.value.map((s) => ({
...s,
transaction: transactions.value.find((tx) => tx.id === s.transactionId) || null,
})),
)
/** 已勾选 AI 建议覆盖的记录总数(聚合组按组内记录数累加) */
const aiTotalCount = computed(() => {
const selected = new Set(checkedAiRowKeys.value)
return aiSuggestions.value
.filter((s) => selected.has(s.transactionId))
.reduce((sum, s) => sum + (s.transactionIds?.length || 1), 0)
})
/** 勾选建议中需要新建的分类名称(去重) */
const aiNewCategoryNames = computed(() => {
const selected = new Set(checkedAiRowKeys.value)
const names = new Set<string>()
aiSuggestions.value.forEach((s) => {
if (selected.has(s.transactionId) && s.newCategoryName) {
names.add(s.newCategoryName)
}
})
return Array.from(names)
})
/** 勾选建议中需要新建的标签名称(去重) */
const aiNewTagNames = computed(() => {
const selected = new Set(checkedAiRowKeys.value)
const names = new Set<string>()
aiSuggestions.value.forEach((s) => {
if (selected.has(s.transactionId) && s.newTags) {
s.newTags.forEach((tag) => names.add(tag.name))
}
})
return Array.from(names)
})
/** AI 建议表格列定义(随语言切换与建议范围响应式更新) */
const aiColumns = computed<DataTableColumns<any>>(() => {
const columns: DataTableColumns<any> = [
{ type: 'selection' },
{
title: t('ai.column.record'), key: 'record', minWidth: 200,
render: (row) => {
const tx = row.transaction
if (!tx) return `#${row.transactionId}`
const time = tx.transactionTime?.replace('T', ' ').substring(0, 16) || '-'
const sign = tx.type === 'INCOME' ? '+' : '-'
return h('div', null, [
h('div', null, `${time} ${sign}${tx.amount.toFixed(2)}`),
h('div', { style: { color: '#999', fontSize: '12px' } }, tx.remark || '-'),
])
},
},
{ title: t('ai.column.count'), key: 'count', width: 90, render: (row) => row.count ?? 1 },
]
// 按建议范围展示对应列
if (aiScope.value !== 'TAG') {
columns.push({
title: t('ai.column.category'), key: 'category', minWidth: 150,
render: (row) => renderCategoryCell(row),
})
}
if (aiScope.value !== 'CATEGORY') {
columns.push({
title: t('ai.column.tags'), key: 'tags', minWidth: 180,
render: (row) => renderTagsCell(row),
})
}
columns.push({ title: t('ai.column.reason'), key: 'reason', minWidth: 180, ellipsis: { tooltip: true } })
return columns
})
/** 渲染 AI 建议的分类单元格,新建分类带「新建」标记 */
function renderCategoryCell(row: AiSuggestion) {
if (row.newCategoryName) {
return h(NSpace, { size: 4, align: 'center', wrap: false }, {
default: () => [
h(NTag, { size: 'small', type: 'info' }, { default: () => t('ai.newBadge') }),
h('span', null, `${row.newCategoryIcon || ''}${row.newCategoryName}`),
],
})
}
return row.categoryName || '-'
}
/** 渲染 AI 建议的标签单元格,新建标签带「新建」标记并使用建议颜色 */
function renderTagsCell(row: AiSuggestion) {
const items: any[] = []
if (row.tagNames && row.tagNames.length > 0) {
items.push(...row.tagNames.map((name) => h(NTag, { size: 'small' }, { default: () => name })))
}
if (row.newTags && row.newTags.length > 0) {
items.push(...row.newTags.map((tag) =>
h(NTag, { size: 'small', color: { color: tag.color, textColor: '#fff' } }, {
default: () => h(NSpace, { size: 2, align: 'center', wrap: false }, {
default: () => [
h('span', null, t('ai.newBadge')),
h('span', null, tag.name),
],
}),
}),
))
}
return items.length > 0 ? h(NSpace, { size: 4, wrap: true }, { default: () => items }) : '-'
}
/** 打开批量设置分类弹窗 */
function handleBatchOpen() {
if (transactions.value.length === 0) {
message.warning(t('transaction.noRecordsToSet'))
return
}
batchExpenseCategoryId.value = null
batchIncomeCategoryId.value = null
batchIncludeInStats.value = 'null'
batchAddTagIds.value = []
batchRemoveTagIds.value = []
showBatchModal.value = true
}
/** 对当前表格展示的记录按类型批量设置分类 */
async function handleBatchSubmit() {
const hasCategoryOrStats = !!(batchExpenseCategoryId.value || batchIncomeCategoryId.value || batchIncludeInStats.value !== 'null')
const hasTags = batchAddTagIds.value.length > 0 || batchRemoveTagIds.value.length > 0
if (!hasCategoryOrStats && !hasTags) {
message.warning(t('transaction.batchNothingSelected'))
return
}
batching.value = true
try {
const ids = transactions.value.map((tx) => tx.id)
if (hasCategoryOrStats) {
await batchUpdateCategory({
ids,
expenseCategoryId: batchExpenseCategoryId.value,
incomeCategoryId: batchIncomeCategoryId.value,
includeInStats: batchIncludeInStats.value === 'true' ? true : batchIncludeInStats.value === 'false' ? false : null,
})
}
if (hasTags) {
await batchTags({
ids,
addTagIds: batchAddTagIds.value,
removeTagIds: batchRemoveTagIds.value,
})
}
message.success(t('transaction.batchUpdated', { count: ids.length }))
showBatchModal.value = false
await fetchData()
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
batching.value = false
}
}
/** 导出中状态,防止重复点击 */
const exporting = ref(false)
/** 导出交易记录:弹出二次确认,确认后执行导出 */
function handleExport() {
dialog.warning({
title: t('transaction.exportConfirmTitle'),
content: t('transaction.exportConfirm'),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
onPositiveClick: () => doExport(),
})
}
/** 实际执行导出交易记录为 Excel 文件 */
async function doExport() {
exporting.value = true
try {
const res = await exportTransaction({
types: filterType.value.length > 0 ? filterType.value : undefined,
...buildCategoryFilter(),
channels: filterChannel.value.length > 0 ? filterChannel.value : undefined,
startTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[0]) : undefined,
endTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[1], true) : undefined,
includeInStats: filterIncludeInStats.value === null ? undefined : filterIncludeInStats.value === 'true',
minAmount: filterMinAmount.value ?? undefined,
maxAmount: filterMaxAmount.value ?? undefined,
remark: filterRemark.value.trim() || undefined,
sortField: sortField.value,
sortOrder: sortOrder.value,
...buildTagFilter(),
})
const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${t('transaction.exportFileName')}_${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.xlsx`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
message.success(t('transaction.exportSuccess'))
} catch (e: any) {
message.error(e.message || t('transaction.exportFailed'))
} finally {
exporting.value = false
}
}
// 组件挂载时加载分类和交易记录
onMounted(() => {
loadFilterPrefs()
remarkHistory.load()
aiQueryHistory.load()
loadAiConfig()
loadExcludedDisplayStyle()
fetchCategories()
fetchTags()
loadChannels()
fetchData()
// 监听剪贴板粘贴,支持在图片记账弹窗中直接粘贴图片
window.addEventListener('paste', handleAiRecordPaste)
})
onBeforeUnmount(() => {
window.removeEventListener('paste', handleAiRecordPaste)
})
</script>
<template>
<div>
<!-- 顶部:标题和操作按钮 -->
<NSpace justify="space-between" align="center" style="margin-bottom: 16px;" :wrap="true">
<h2 style="margin: 0;">{{ t('transaction.title') }}</h2>
<NSpace align="center" :wrap="true">
<span style="color: #999; font-size: 13px;">{{ t('transaction.sortLabel') }}</span>
<NSelect v-model:value="sortValue" :options="sortOptions" style="width: 170px;" />
<NDivider vertical style="margin: 0 4px;" />
<NButton @click="handleBatchOpen">{{ t('transaction.batchSet') }}</NButton>
<NButton type="error" :disabled="checkedTransactionIds.length === 0" @click="handleBatchDelete">
{{ t('transaction.batchDelete') }}
</NButton>
<NButton @click="showImportModal = true">{{ t('transaction.importBill') }}</NButton>
<NButton @click="goChannelManage">{{ t('channel.manageEntry') }}</NButton>
<NButton :loading="exporting" @click="handleExport">{{ t('transaction.exportExcel') }}</NButton>
<NButton :loading="loading" @click="fetchData">
<template #icon>
<NIcon :component="RefreshOutline" />
</template>
{{ t('common.refresh') }}
</NButton>
<NButton v-if="aiEnabled" @click="handleAiRecordOpen">
<template #icon>
<NIcon :component="SparklesOutline" />
</template>
{{ t('ai.record.button') }}
</NButton>
<NButton type="primary" @click="handleAdd">{{ t('transaction.addRecord') }}</NButton>
</NSpace>
</NSpace>
<!-- 查询与筛选 -->
<NCard :bordered="false" style="margin-bottom: 16px;">
<!-- 常用筛选条件 -->
<NSpace align="center" :wrap="true" :size="[12, 8]">
<NSelect
v-model:value="filterType"
:options="typeOptions"
multiple
clearable
:max-tag-count="2"
:placeholder="t('transaction.column.type')"
style="width: 160px;"
/>
<NSelect
v-model:value="filterCategory"
:options="categoryFilterOptions"
:render-label="renderCategoryLabel"
:render-tag="renderCategoryTag"
multiple
clearable
filterable
:max-tag-count="2"
:placeholder="t('transaction.column.category')"
style="width: 200px;"
/>
<NDatePicker
v-model:value="filterDateRange"
type="daterange"
clearable
style="width: 260px;"
/>
<NAutoComplete
:value="filterRemark"
: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="remarkHistory.handleInput"
@select="handleRemarkSelect"
@blur="handleRemarkBlur"
/>
<NSelect
v-model:value="filterChannel"
:options="channelOptions"
multiple
clearable
:max-tag-count="2"
:placeholder="t('transaction.column.channel')"
style="width: 180px;"
/>
<NPopover v-model:show="showMoreFilter" trigger="click" placement="bottom-start">
<template #trigger>
<NButton>
{{ t('transaction.moreFilter') }}
<NTag v-if="moreFilterCount > 0" :bordered="false" size="small" type="primary" style="margin-left: 6px;">
{{ moreFilterCount }}
</NTag>
</NButton>
</template>
<NSpace vertical :size="12" style="width: 260px;">
<div>
<div class="filter-label">{{ t('transaction.column.tags') }}</div>
<NSelect
v-model:value="filterTag"
:options="tagFilterSelectOptions"
multiple
clearable
filterable
:max-tag-count="2"
:placeholder="t('common.all')"
style="width: 100%;"
/>
</div>
<div>
<div class="filter-label">{{ t('transaction.column.includeInStats') }}</div>
<NSelect
v-model:value="filterIncludeInStats"
:options="includeInStatsOptions"
clearable
:placeholder="t('common.all')"
style="width: 100%;"
/>
</div>
<div>
<div class="filter-label">{{ t('transaction.amountRange') }}</div>
<NSpace align="center" :size="8">
<NInputNumber
v-model:value="filterMinAmount"
:min="0"
:precision="2"
clearable
:placeholder="t('common.min')"
style="width: 110px;"
/>
<span style="color: #999;">~</span>
<NInputNumber
v-model:value="filterMaxAmount"
:min="0"
:precision="2"
clearable
:placeholder="t('common.max')"
style="width: 110px;"
/>
</NSpace>
</div>
<NSpace justify="end">
<NButton size="small" @click="clearMoreFilters">{{ t('transaction.clear') }}</NButton>
</NSpace>
</NSpace>
</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>
<!-- 已激活的筛选条件 -->
<div v-if="activeFilterTags.length > 0" class="active-filters">
<NSpace align="center" :wrap="true" :size="[8, 8]">
<span style="color: #999; font-size: 12px;">{{ t('transaction.activeFilters') }}</span>
<NTag
v-for="tag in activeFilterTags"
:key="tag.key"
size="small"
closable
@close="applyTagClear(tag)"
>
{{ tag.label }}
</NTag>
<NButton text size="tiny" type="primary" @click="handleReset">{{ t('transaction.clearAll') }}</NButton>
</NSpace>
</div>
</NCard>
<!-- 表格信息栏(右上角) -->
<NSpace justify="end" align="center" :size="12" style="margin-bottom: 8px;">
<span style="color: #999; font-size: 12px;">
{{ t('transaction.summary', { total, page, pages: totalPages, size: pageSize }) }}
</span>
<span style="font-size: 12px; color: #999;">
{{ t('transaction.pageExpense') }} <span style="color: #d03050;">{{ pageSummary.expense.toFixed(2) }}</span>
· {{ t('transaction.pageIncome') }} <span style="color: #18a058;">{{ pageSummary.income.toFixed(2) }}</span>
· {{ t('transaction.pageBalance') }} <span :style="{ color: pageSummary.balance < 0 ? '#d03050' : '#18a058' }">{{ pageSummary.balance.toFixed(2) }}</span>
<span style="margin-left: 8px;">
{{ t('transaction.pageExcluded', { expense: pageExcludedSummary.expense.toFixed(2), income: pageExcludedSummary.income.toFixed(2) }) }}
</span>
</span>
</NSpace>
<!-- 交易记录数据表格 -->
<NDataTable
:columns="columns"
:data="transactions"
:loading="loading"
:bordered="false"
:row-key="(row: TransactionItem) => row.id"
:row-class-name="(row: TransactionItem) => (!row.includeInStats && excludedDisplayStyle === 'strikethrough') ? 'excluded-row' : ''"
v-model:checked-row-keys="checkedTransactionIds"
/>
<!-- 分页控件 -->
<NSpace justify="center" style="margin-top: 16px;">
<NPagination
v-model:page="page"
v-model:page-size="pageSize"
:page-count="Math.ceil(total / pageSize)"
:page-sizes="pageSizes"
show-size-picker
show-quick-jumper
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</NSpace>
<!-- 新增/编辑弹窗 -->
<NModal v-model:show="showModal" preset="card" :title="editingId ? t('transaction.editRecord') : t('transaction.addRecordTitle')" style="width: 500px;">
<NForm>
<NFormItem :label="t('transaction.column.type')">
<NSelect v-model:value="form.type" :options="typeOptions" />
</NFormItem>
<NFormItem :label="t('transaction.column.category')">
<div style="display: flex; align-items: center; gap: 8px; width: 100%;">
<NSelect v-model:value="form.categoryId" :options="categories.filter(c => c.type === form.type)" :render-label="renderCategoryLabel" :placeholder="t('transaction.selectCategoryPlaceholder')" filterable style="flex: 1;" />
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiFormLoading" @click="handleAiSuggestOne">{{ t('ai.pickButton') }}</NButton>
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiNewLoading" @click="handleAiNewCategory">{{ t('ai.newCategory.button') }}</NButton>
</div>
</NFormItem>
<!-- AI 生成的新分类确认条:用户确认后才保存 -->
<div v-if="pendingCategory" style="display: flex; align-items: center; gap: 8px; margin-bottom: 18px; padding: 8px 12px; background: #f7f7fa; border-radius: 4px; font-size: 13px;">
<span>{{ t('ai.newCategory.hint') }}</span>
<span style="font-weight: 600;">{{ pendingCategory.icon }} {{ pendingCategory.name }}</span>
<NButton size="tiny" type="primary" :loading="aiNewSaving" @click="handleSaveNewCategory">{{ t('ai.newCategory.save') }}</NButton>
<NButton size="tiny" @click="pendingCategory = null">{{ t('common.cancel') }}</NButton>
</div>
<NFormItem :label="t('transaction.column.amount')">
<NInputNumber v-model:value="form.amount" :min="0.01" :precision="2" style="width: 100%;" :placeholder="t('transaction.amountPlaceholder')" />
</NFormItem>
<NFormItem :label="t('transaction.column.channel')">
<div style="display: flex; align-items: center; gap: 8px; width: 100%;">
<NSelect v-model:value="form.channel" :options="channelOptions" clearable :placeholder="t('transaction.channelPlaceholder')" style="flex: 1;" />
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiChannelLoading" @click="handleAiChannelSuggest">{{ t('ai.pickButton') }}</NButton>
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiChannelNewLoading" @click="handleAiNewChannel">{{ t('ai.newChannel.button') }}</NButton>
</div>
</NFormItem>
<!-- AI 生成的新渠道确认条:用户确认后才保存 -->
<div v-if="pendingChannel" style="display: flex; align-items: center; gap: 8px; margin-bottom: 18px; padding: 8px 12px; background: #f7f7fa; border-radius: 4px; font-size: 13px;">
<span>{{ t('ai.newChannel.hint') }}</span>
<span style="font-weight: 600;">{{ pendingChannel.name }}</span>
<NButton size="tiny" type="primary" :loading="aiChannelNewSaving" @click="handleSaveNewChannel">{{ t('ai.newChannel.save') }}</NButton>
<NButton size="tiny" @click="pendingChannel = null">{{ t('common.cancel') }}</NButton>
</div>
<NFormItem :label="t('transaction.column.transactionTime')">
<NDatePicker v-model:value="form.transactionTime" type="datetime" style="width: 100%;" />
</NFormItem>
<NFormItem :label="t('transaction.column.remark')">
<NInput v-model:value="form.remark" type="textarea" :placeholder="t('transaction.remarkPlaceholder')" />
</NFormItem>
<NFormItem :label="t('transaction.column.tags')">
<div style="display: flex; align-items: center; gap: 8px; width: 100%;">
<NSelect
v-model:value="form.tagIds"
:options="tagFilterOptions"
multiple
clearable
filterable
:placeholder="t('transaction.tagPlaceholder')"
style="flex: 1;"
/>
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiTagSuggestLoading" @click="handleAiTagSuggest">{{ t('ai.pickButton') }}</NButton>
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiTagNewLoading" @click="handleAiNewTag">{{ t('ai.newTag.button') }}</NButton>
</div>
</NFormItem>
<!-- AI 生成的新标签确认条:用户确认后才保存 -->
<div v-if="pendingTag" style="display: flex; align-items: center; gap: 8px; margin-bottom: 18px; padding: 8px 12px; background: #f7f7fa; border-radius: 4px; font-size: 13px;">
<span>{{ t('ai.newTag.hint') }}</span>
<NTag size="small" :color="{ color: pendingTag.color, textColor: '#fff' }">{{ pendingTag.name }}</NTag>
<NButton size="tiny" type="primary" :loading="aiTagSaving" @click="handleSaveNewTag">{{ t('ai.newTag.save') }}</NButton>
<NButton size="tiny" @click="pendingTag = null">{{ t('common.cancel') }}</NButton>
</div>
<NFormItem :label="t('transaction.includeInStats')">
<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 :loading="submitting" @click="handleSubmit">{{ t('common.confirm') }}</NButton>
</NForm>
</NModal>
<!-- 账单导入弹窗 -->
<BillImportModal v-model:show="showImportModal" @imported="fetchData" />
<!-- 原始账单数据弹窗 -->
<NModal v-model:show="showRawModal" preset="card" :title="t('transaction.rawDataTitle')" style="width: 640px;">
<NInput :value="rawDataText" type="textarea" readonly :autosize="{ minRows: 8, maxRows: 20 }" />
</NModal>
<!-- 来源图片查看弹窗(图片记账生成的记录):默认加载缩略图,可切换原图 -->
<NModal v-model:show="showImageViewModal" preset="card" :title="t('transaction.sourceImages')" style="width: 720px; max-width: 92vw;" @after-leave="closeImageView">
<NSpin :show="imageViewLoading">
<NSpace vertical :size="12" style="min-height: 60px;">
<div v-for="item in imageViewItems" :key="item.id" class="image-view-item">
<img :src="item.url" alt="source image" style="width: 100%; max-height: 70vh; object-fit: contain; border-radius: 6px;" />
<div class="image-view-actions">
<NTag v-if="item.isOriginal" size="small" type="success">{{ t('transaction.originalImage') }}</NTag>
<NButton v-else size="small" :loading="item.loading" @click="loadOriginalImage(item)">
{{ t('transaction.loadOriginal') }}
</NButton>
</div>
</div>
</NSpace>
</NSpin>
</NModal>
<!-- 一键设置弹窗(作用于当前表格展示的记录) -->
<NModal v-model:show="showBatchModal" preset="card" :title="t('transaction.batchTitle')" style="width: 480px;">
<NForm>
<NFormItem :label="t('transaction.batchScope')">
<span style="color: #999; font-size: 12px;">
{{ t('transaction.batchScopeHint', { count: transactions.length }) }}
</span>
</NFormItem>
<NFormItem v-if="aiEnabled" :label="t('ai.suggestButton')">
<div style="display: flex; gap: 8px; width: 100%;">
<NSelect v-model:value="aiScope" :options="aiScopeOptions" style="flex: 1;" />
<NButton :loading="aiLoading" @click="handleAiSuggest">{{ t('ai.suggestButton') }}</NButton>
</div>
</NFormItem>
<NFormItem v-if="hasExpenseRecords" :label="t('transaction.expenseCategory')">
<NSelect
v-model:value="batchExpenseCategoryId"
:options="categories.filter(c => c.type === 'EXPENSE')"
:render-label="renderCategoryLabel"
filterable
clearable
:placeholder="t('transaction.notSetExpense')"
/>
</NFormItem>
<NFormItem v-if="hasIncomeRecords" :label="t('transaction.incomeCategory')">
<NSelect
v-model:value="batchIncomeCategoryId"
:options="categories.filter(c => c.type === 'INCOME')"
:render-label="renderCategoryLabel"
filterable
clearable
:placeholder="t('transaction.notSetIncome')"
/>
</NFormItem>
<NFormItem :label="t('transaction.includeInStats')">
<NSelect v-model:value="batchIncludeInStats" :options="batchStatsOptions" />
</NFormItem>
<NFormItem :label="t('transaction.addTag')">
<NSelect v-model:value="batchAddTagIds" :options="tagFilterOptions" multiple clearable filterable :placeholder="t('transaction.notAddTag')" />
</NFormItem>
<NFormItem :label="t('transaction.removeTag')">
<NSelect v-model:value="batchRemoveTagIds" :options="tagFilterOptions" multiple clearable filterable :placeholder="t('transaction.notRemoveTag')" />
</NFormItem>
<NButton type="primary" block :loading="batching" @click="handleBatchSubmit">{{ t('common.confirm') }}</NButton>
</NForm>
</NModal>
<!-- AI 建议弹窗(一键应用分类与标签建议) -->
<NModal v-if="aiEnabled" v-model:show="showAiModal" preset="card" :title="t('ai.suggestTitle')" style="width: 860px; max-width: 92vw;">
<NSpace vertical :size="12">
<NAlert type="warning" :show-icon="true">{{ t('ai.applyHint') }}</NAlert>
<NDataTable
v-model:checked-row-keys="checkedAiRowKeys"
:columns="aiColumns"
:data="aiRows"
:max-height="420"
:row-key="(row) => row.transactionId"
size="small"
:bordered="false"
/>
<NSpace justify="end">
<NButton @click="showAiModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="aiApplying" :disabled="checkedAiRowKeys.length === 0" @click="handleAiApply">
{{ t('ai.applyAll', { count: aiTotalCount }) }}
</NButton>
</NSpace>
</NSpace>
</NModal>
<!-- AI 智能记账弹窗(文字 / 图片) -->
<NModal v-if="aiEnabled" v-model:show="showAiRecordModal" preset="card" :title="t('ai.record.title')" style="width: 560px; max-width: 92vw;">
<NTabs v-model:value="aiRecordTab" type="line" animated>
<NTabPane name="text" :tab="t('ai.record.textTab')">
<NSpace vertical :size="12">
<NAlert type="info" :show-icon="true">{{ t('ai.record.hint') }}</NAlert>
<NInput
v-model:value="aiRecordText"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
:placeholder="t('ai.record.placeholder')"
/>
<NSpace justify="end">
<NButton @click="showAiRecordModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="aiRecording" @click="handleAiRecordParse">{{ t('ai.record.parse') }}</NButton>
</NSpace>
</NSpace>
</NTabPane>
<NTabPane name="image" :tab="t('ai.record.imageTab')">
<NSpace vertical :size="12">
<NAlert type="info" :show-icon="true">{{ t('ai.record.imageHint') }}</NAlert>
<NUpload
:file-list="aiRecordFileList"
:default-upload="false"
:max="MAX_AI_RECORD_IMAGES"
multiple
accept="image/jpeg,image/png,image/gif,image/webp"
@change="handleAiRecordFileChange"
>
<NUploadDragger>
<NIcon size="36" :component="CloudUploadOutline" />
<div style="margin-top: 8px;">{{ t('ai.record.upload') }}</div>
<div style="margin-top: 4px; font-size: 12px; color: #999;">{{ t('ai.record.uploadTip') }}</div>
<div style="margin-top: 4px; font-size: 12px; color: #999;">{{ t('ai.record.pasteHint') }}</div>
</NUploadDragger>
</NUpload>
<NInput v-model:value="aiRecordHint" :placeholder="t('ai.record.placeholder')" />
<!-- 识别进度实时日志(SSE 推送) -->
<div v-if="aiRecordProgress.length > 0" class="ai-progress">
<div class="ai-progress-title">
<NIcon :component="SparklesOutline" />
<span>{{ t('ai.record.progressTitle') }}</span>
</div>
<div class="ai-progress-list">
<div
v-for="(item, index) in aiRecordProgress"
:key="index"
class="ai-progress-item"
:class="{ 'is-running': index === aiRecordProgress.length - 1 && aiRecordingImage }"
>
<NIcon
class="ai-progress-icon"
:component="index === aiRecordProgress.length - 1 && aiRecordingImage ? RefreshOutline : CheckmarkCircleOutline"
/>
<span class="ai-progress-time">{{ item.time }}</span>
<span class="ai-progress-text">{{ item.text }}</span>
</div>
</div>
</div>
<NSpace justify="end">
<NButton @click="handleAiRecordCancel">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="aiRecordingImage" :disabled="aiRecordFiles.length === 0 || aiRecordingImage" @click="handleAiRecordParseImage">
{{ t('ai.record.parseImage') }}
</NButton>
</NSpace>
</NSpace>
</NTabPane>
</NTabs>
</NModal>
<!-- 图片记账结果弹窗 -->
<NModal v-if="aiEnabled" v-model:show="showAiRecordResultModal" preset="card" :title="t('ai.record.resultTitle')" style="width: 960px; max-width: 96vw;">
<NSpace vertical :size="12">
<NAlert type="info" :show-icon="true">
{{ aiRecordSummary || t('ai.record.resultHint') }}
</NAlert>
<NDataTable
v-model:checked-row-keys="checkedAiRecordKeys"
:columns="aiRecordColumns"
:data="aiRecordRows"
:row-key="(row) => row.key"
:max-height="420"
size="small"
:bordered="false"
/>
<NSpace justify="end">
<NButton @click="showAiRecordResultModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="aiRecordImporting" :disabled="checkedAiRecordKeys.length === 0" @click="handleAiRecordImport">
{{ t('ai.record.importSelected', { count: checkedAiRecordKeys.length }) }}
</NButton>
</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>
<style scoped>
.remark-cell {
display: flex;
align-items: center;
}
.remark-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-label {
margin-bottom: 4px;
color: #999;
font-size: 12px;
}
.active-filters {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
/* 不计入统计的记录整行加删除线 */
:deep(.excluded-row .n-data-table-td) {
text-decoration: line-through;
}
/* 类型、金额列保持现状,不划删除线 */
:deep(.excluded-row .n-data-table-td[data-col-key="type"]),
:deep(.excluded-row .n-data-table-td[data-col-key="amount"]) {
text-decoration: none;
}
/* 备注单元格为块级结构,需单独划线以保持整行效果 */
:deep(.excluded-row .remark-text) {
text-decoration: line-through;
}
/* 图片识别进度:浅色卡片 + 步骤列表 */
.image-view-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.image-view-actions {
display: flex;
justify-content: flex-end;
}
.ai-progress {
background: #f7f9fc;
border: 1px solid #eef1f6;
border-radius: 8px;
padding: 10px 12px;
}
.ai-progress-title {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
color: #666;
}
.ai-progress-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 180px;
overflow-y: auto;
}
.ai-progress-item {
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 13px;
line-height: 1.5;
color: #555;
}
.ai-progress-icon {
margin-top: 2px;
flex-shrink: 0;
color: #18a058;
}
/* 最新一条处理中时用蓝色旋转图标 */
.ai-progress-item.is-running .ai-progress-icon {
color: #2080f0;
animation: ai-progress-spin 1s linear infinite;
}
.ai-progress-time {
flex-shrink: 0;
margin-top: 1px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
color: #a0a4ab;
}
.ai-progress-text {
word-break: break-all;
}
@keyframes ai-progress-spin {
to {
transform: rotate(360deg);
}
}
</style>