Files
ledgerly/frontend/src/views/TransactionList.vue
T
rosercode 863e221220 refactor: 拆分交易记录前后端巨型文件并优化批量与导出性能
将 TransactionServiceImpl(922 行)与 TransactionList.vue(2811 行)按职责拆分为多个协作组件,对外接口与既有行为保持兼容,并在拆分后的组件中修复批量创建 N+1 查询与导出全量加载的内存风险。

变更内容:

- 后端拆分:TransactionServiceImpl 作为门面保留 TransactionService 契约,抽出 TransactionQuerySupport(查询条件构建)、TransactionVoEnricher(视图富化)、TransactionTagWriter(标签写入)、TransactionBatchService(批量操作)与 exporter.TransactionExportAssembler(导出组装)
- 前端拆分:TransactionList.vue 抽出 AiRecordModal、AiSuggestModal、TransactionBatchModal 三个弹窗组件与 utils/datetime 工具,并将本地 AI 启用状态迁移到共享 useAiEnabled
- 性能:batchCreate 预加载分类消除循环内 N+1 并改为批量写入标签关联;export 增加 1 万条上限保护,超限提示缩小筛选范围
2026-10-09 14:29:00 +08:00

2010 lines
75 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, 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, NDivider, NColorPicker } from 'naive-ui'
import { EyeOutline, EyeOffOutline, SparklesOutline, RefreshOutline, StatsChartOutline } from '@vicons/ionicons5'
import type { DataTableColumns } from 'naive-ui'
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, getChannels, batchDeleteTransactions } from '../api/transaction'
import { suggestByAi, suggestOneByAi, queryByAi, parseCategoryByAi, parseTagByAi, parseChannelByAi } from '../api/ai'
import { createChannel } from '../api/channel'
import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai'
import { getExcludedDisplayStyle, getShowHiddenData } 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 { useStringPreference } from '../composables/usePreference'
import { usePageSizeOptions } from '../composables/usePageSizeOptions'
import { useTablePageSize } from '../composables/useTablePageSize'
import { readTablePageSize, writeTablePageSize } from '../utils/tablePageSizePrefs'
import { STORAGE_KEYS } from '../utils/storageKeys'
import { loadTransactionFilterPrefs, saveTransactionFilterPrefs } from '../utils/transactionFilterPrefs'
import { formatIsoBoundary as formatTimestamp, formatLocalDateTime, formatDateLabel } from '../utils/datetime'
import { aiEnabled, ensureAiEnabledLoaded } from '../composables/useAiEnabled'
import EmojiPicker from '../components/EmojiPicker.vue'
import AiRecordModal from '../components/AiRecordModal.vue'
import AiSuggestModal from '../components/AiSuggestModal.vue'
import TransactionBatchModal from '../components/TransactionBatchModal.vue'
import BillImportModal from './BillImportModal.vue'
import TransactionDetailModal from '../components/TransactionDetailModal.vue'
import TransactionRelationEditorModal from '../components/TransactionRelationEditorModal.vue'
import TransactionRelationModal from '../components/TransactionRelationModal.vue'
import AiQueryWindow from '../components/AiQueryWindow.vue'
import type { RelationMemberDraft } from '../api/transactionGroup'
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[]
/** 所属关系组数量(父子/同级关系) */
relationGroupCount: number
createdAt: string
}
/** 分类选项数据结构 */
interface CategoryOption {
label: string
value: number
type: string
icon: string
}
/** 标签数据结构 */
interface TagOption {
id: number
name: string
color: string
}
// 列表数据和分页状态
const transactions = ref<TransactionItem[]>([]) // 交易记录列表
const total = ref(0) // 总记录数
const page = ref(1) // 当前页码
const pageSize = useTablePageSize('transaction') // 每页条数
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 { pageSizeOptions } = usePageSizeOptions() // 所有表格共用的每页条数候选列表
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 showHiddenData = ref(false)
const submitting = ref(false) // 表单提交状态
const showModal = ref(false) // 新增/编辑弹窗显示状态
const showImportModal = ref(false) // 账单导入弹窗显示状态
const showDetailModal = ref(false) // 账单详情弹窗显示状态
const detailTransaction = ref<TransactionItem | null>(null) // 当前查看详情的账单
const showRelationEditor = ref(false) // 勾选账单建立关系弹窗显示状态
const relationEditorMembers = ref<RelationMemberDraft[]>([]) // 建立关系时的预填成员
const showRelationModal = ref(false) // 关系查看弹窗显示状态
const relationTransaction = ref<TransactionItem | null>(null) // 当前查看关系的账单
const showBatchModal = ref(false) // 批量设置分类弹窗显示状态
const showAiModal = ref(false) // AI 建议弹窗显示状态
const aiLoading = ref(false) // AI 建议生成状态
const aiFormLoading = ref(false) // 编辑弹窗内 AI 推荐状态
/** AI 建议范围偏好存储键 */
const AI_SCOPE_KEY = STORAGE_KEYS.transactionAiScope
// 批量 AI 建议范围:BOTH/CATEGORY/TAG(记忆用户偏好,非法值回退 BOTH)
const aiScope = useStringPreference(AI_SCOPE_KEY, 'BOTH', {
validate: (value) => ['BOTH', 'CATEGORY', 'TAG'].includes(value),
})
// 批量 AI 建议范围选项
const aiScopeOptions = computed(() => [
{ label: t('ai.scope.both'), value: 'BOTH' },
{ label: t('ai.scope.category'), value: 'CATEGORY' },
{ label: t('ai.scope.tag'), value: 'TAG' },
])
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)
// 手动新建分类弹窗:与 AI 新建相互独立,不受 AI 启用状态控制
const showNewCategoryModal = ref(false) // 新建分类弹窗显示状态
const newCategorySaving = ref(false) // 新建分类保存状态
const newCategoryForm = ref<{ name: string; type: string; icon: string }>({ name: '', type: 'EXPENSE', icon: '' })
// 手动新建标签弹窗
const showNewTagModal = ref(false) // 新建标签弹窗显示状态
const newTagSaving = ref(false) // 新建标签保存状态
const newTagForm = ref<{ name: string; color: string }>({ name: '', color: '#18a058' })
// 手动新建渠道弹窗
const showNewChannelModal = ref(false) // 新建渠道弹窗显示状态
const newChannelSaving = ref(false) // 新建渠道保存状态
const newChannelForm = ref<{ name: string }>({ name: '' })
// 新建标签可选预设色板(与标签管理页保持一致)
const newTagColorSwatches = [
'#18a058', '#2080f0', '#f0a020', '#d03050', '#7c3aed', '#13c2c2',
'#eb2f96', '#fa8c16', '#2f54eb', '#52c41a', '#8c8c8c', '#000000',
]
const aiSuggestions = ref<AiSuggestion[]>([]) // AI 建议列表
const aiQuerying = ref(false) // AI 查询解析状态
const aiQueryResult = ref<AiQueryResult | null>(null) // AI 查询解析结果
const showAiQueryModal = ref(false) // AI 智能查询弹窗显示状态
const showAiRecordModal = ref(false) // AI 记账弹窗显示状态
/** AI 查询历史与浮窗位置尺寸存储键 */
const AI_QUERY_HISTORY_KEY = STORAGE_KEYS.transactionAiQueryHistory
const AI_QUERY_WINDOW_KEY = STORAGE_KEYS.transactionAiQueryWindow
// 一键设置目标记录快照:有勾选时取勾选记录,否则取当前表格展示的记录
const batchTargets = ref<TransactionItem[]>([])
// 目标记录是否来自勾选,用于作用范围提示文案
const batchFromSelection = ref(false)
// 目标记录ID列表
const batchTargetIds = computed(() => batchTargets.value.map((tx) => tx.id))
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 selectionMode = ref(false) // 批量选择模式:开启后才显示多选列
// 表单数据(新增/编辑共用)
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>('true') // 计入统计筛选:null-全部,'true'-计入,'false'-不计入(默认仅计入,与统计面板一致)
const filterRemark = ref('') // 备注关键字搜索
// 备注关键词历史存储键
const REMARK_HISTORY_KEY = STORAGE_KEYS.transactionRemarkHistory
// 备注关键词历史(最近输入,最新在前)
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' },
])
/** 从 localStorage 读取上次的筛选条件偏好并还原 */
function loadFilterPrefs() {
const p = loadTransactionFilterPrefs()
if (p.filterType) filterType.value = p.filterType
if (p.filterCategory) filterCategory.value = p.filterCategory
if (p.filterChannel) filterChannel.value = p.filterChannel
if (p.filterDateRange) filterDateRange.value = p.filterDateRange
if (p.filterIncludeInStats !== undefined) filterIncludeInStats.value = p.filterIncludeInStats
if (typeof p.filterRemark === 'string') filterRemark.value = p.filterRemark
if (p.filterMinAmount !== undefined) filterMinAmount.value = p.filterMinAmount
if (p.filterMaxAmount !== undefined) filterMaxAmount.value = p.filterMaxAmount
// 迁移旧筛选偏好中的每页条数到统一的表格分页偏好(新机制尚无值时)
if (typeof p.pageSize === 'number' && Number.isInteger(p.pageSize) && p.pageSize > 0
&& readTablePageSize('transaction') === null) {
writeTablePageSize('transaction', p.pageSize)
pageSize.value = p.pageSize
}
if (typeof p.sortValue === 'string' && sortOptions.value.some((o) => o.value === p.sortValue)) sortValue.value = p.sortValue
if (p.filterTag) filterTag.value = p.filterTag
}
/** 将当前筛选条件写入 localStorage,下次进入自动还原 */
function saveFilterPrefs() {
saveTransactionFilterPrefs({
filterType: filterType.value,
filterCategory: filterCategory.value,
filterChannel: filterChannel.value,
filterDateRange: filterDateRange.value,
filterIncludeInStats: filterIncludeInStats.value,
filterRemark: filterRemark.value,
filterMinAmount: filterMinAmount.value,
filterMaxAmount: filterMaxAmount.value,
sortValue: sortValue.value,
filterTag: filterTag.value,
})
}
// 筛选条件变化时自动保存用户偏好
watch(
[filterType, filterCategory, filterChannel, filterDateRange, filterIncludeInStats, filterRemark, filterMinAmount, filterMaxAmount, 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()
}
}
/** 分类已选项(多选标签)渲染:图标 + 名称 */
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'))
// 偏好为不显示时,隐藏后将该行从列表移除;当前页清空则回退一页
if (hidden && !showHiddenData.value) {
const index = transactions.value.findIndex((item) => item.id === row.id)
if (index >= 0) {
transactions.value.splice(index, 1)
total.value = Math.max(0, total.value - 1)
}
if (transactions.value.length === 0 && page.value > 1) {
page.value -= 1
fetchData()
}
}
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
}
}
/** 创建表格列配置 */
function createColumns(): DataTableColumns<TransactionItem> {
// 读取展示样式,使列配置随样式偏好变化而重新计算
const muted = excludedDisplayStyle.value === 'muted'
return [
// 多选列:仅在批量选择模式下显示,默认不渲染复选框
...(selectionMode.value ? [{ type: 'selection' as const }] : []),
{
// 序号列:按分页计算全局序号
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.relation.column'), key: 'relationGroupCount', width: 110, align: 'center',
render(row) {
if (!row.relationGroupCount) {
return h('span', { style: { color: '#ccc' } }, t('transaction.relation.none'))
}
return h(NButton, { size: 'small', quaternary: true, type: 'info', onClick: () => openRelation(row) },
{ default: () => t('transaction.relation.viewCount', { count: row.relationGroupCount }) })
},
},
{
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', onClick: () => openDetail(row) }, { default: () => t('transaction.detail.button') }),
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')
}
/**
* 携带当前筛选条件跳转到统计面板
*
* 先将当前筛选写入共享偏好(与统计面板共用),再带来源标记跳转,
* 统计面板据此还原同一套筛选条件并生成对应统计信息。
*/
function goStatistics() {
saveFilterPrefs()
router.push({ path: '/dashboard', query: { from: 'transactions' } })
}
/** 加载「不计入统计」记录展示样式偏好,失败时保持默认删除线 */
async function loadExcludedDisplayStyle() {
try {
const res: any = await getExcludedDisplayStyle()
if (res.data === 'muted' || res.data === 'strikethrough') {
excludedDisplayStyle.value = res.data
}
} catch (e) {
// 静默处理,保持默认样式
}
}
/** 加载「显示已隐藏备注记录」偏好,失败时保持默认不显示 */
async function loadShowHiddenData() {
try {
const res: any = await getShowHiddenData()
showHiddenData.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()
})
/** 打开账单详情弹窗 */
function openDetail(row: TransactionItem) {
detailTransaction.value = row
showDetailModal.value = true
}
/** 打开账单关系弹窗 */
function openRelation(row: TransactionItem) {
relationTransaction.value = row
showRelationModal.value = true
}
/** 从详情弹窗进入编辑 */
function handleDetailEdit() {
if (detailTransaction.value) {
showDetailModal.value = false
handleEdit(detailTransaction.value)
}
}
/** 以表格勾选的账单为成员,打开建立关系弹窗 */
function handleCreateRelation() {
if (checkedTransactionIds.value.length < 2) {
message.warning(t('transaction.relation.selectAtLeastTwo'))
return
}
const selected = transactions.value.filter((row) => checkedTransactionIds.value.includes(row.id))
relationEditorMembers.value = selected.map((row) => ({
transactionId: row.id,
type: row.type,
amount: row.amount,
channel: row.channel,
remark: row.remark,
transactionTime: row.transactionTime,
categoryName: row.categoryName,
categoryIcon: row.categoryIcon,
}))
showRelationEditor.value = true
}
/** 建立关系保存成功后刷新列表(选择随刷新清空) */
function onRelationEditorSaved() {
fetchData()
}
/** 打开新增记录弹窗,重置表单 */
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 enterSelectionMode() {
selectionMode.value = true
}
/** 退出批量选择模式并清空已选 */
function exitSelectionMode() {
selectionMode.value = false
checkedTransactionIds.value = []
}
/** 翻页事件处理 */
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 handleAiSuggest() {
if (batchTargetIds.value.length === 0) {
message.warning(t('transaction.noRecordsToSet'))
return
}
aiLoading.value = true
try {
const ids = batchTargetIds.value
const res: any = await suggestByAi(ids, aiScope.value)
aiSuggestions.value = res.data || []
if (aiSuggestions.value.length === 0) {
message.warning(t('ai.noSuggestion'))
return
}
// 默认全选由建议弹窗在打开时处理,这里仅提示聚合结果
const total = aiSuggestions.value.reduce((sum, s) => sum + (s.transactionIds?.length || 1), 0)
message.success(t('ai.aggregated', { groups: aiSuggestions.value.length, count: total }))
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]
}
}
/** 打开手动新建分类弹窗,类型默认跟随当前账单类型 */
function openNewCategory() {
newCategoryForm.value = { name: '', type: form.value.type, icon: '' }
showNewCategoryModal.value = true
}
/** 手动新建分类并选用:创建后刷新分类列表,回填分类ID并同步收支类型 */
async function handleCreateCategory() {
const name = newCategoryForm.value.name.trim()
if (!name) {
message.warning(t('category.nameRequired'))
return
}
newCategorySaving.value = true
try {
await createCategory({
name,
type: newCategoryForm.value.type,
icon: newCategoryForm.value.icon || undefined,
sortOrder: 0,
})
await fetchCategories()
// 排序值相同,取同名同类型中的最后一个(最新创建)
const created = [...categories.value].reverse().find((c) => c.label === name && c.type === newCategoryForm.value.type)
if (created) {
form.value.categoryId = created.value
// 同步收支类型,避免新建分类与当前账单类型不一致导致选中项不可见
form.value.type = created.type
}
message.success(t('common.createSuccess'))
showNewCategoryModal.value = false
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
newCategorySaving.value = false
}
}
/** 打开手动新建标签弹窗,使用默认颜色 */
function openNewTag() {
newTagForm.value = { name: '', color: '#18a058' }
showNewTagModal.value = true
}
/** 手动新建标签并选用:创建后刷新标签列表并追加到已选标签 */
async function handleCreateTag() {
const name = newTagForm.value.name.trim()
if (!name) {
message.warning(t('tag.nameRequired'))
return
}
newTagSaving.value = true
try {
await createTag({ name, color: newTagForm.value.color, sortOrder: 0 })
await fetchTags()
// 排序值相同,取同名中的最后一个(最新创建)
const created = [...allTags.value].reverse().find((tag) => tag.name === name)
if (created) {
addTagToForm(created.id)
}
message.success(t('common.createSuccess'))
showNewTagModal.value = false
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
newTagSaving.value = false
}
}
/** 打开手动新建渠道弹窗 */
function openNewChannel() {
newChannelForm.value = { name: '' }
showNewChannelModal.value = true
}
/** 手动新建渠道并选用:创建后刷新渠道列表并回填渠道编码 */
async function handleCreateChannel() {
const name = newChannelForm.value.name.trim()
if (!name) {
message.warning(t('channel.nameRequired'))
return
}
newChannelSaving.value = true
try {
const res: any = await createChannel(name)
await loadChannels()
const created = res?.data
if (created?.code) {
form.value.channel = created.code
}
message.success(t('common.createSuccess'))
showNewChannelModal.value = false
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
newChannelSaving.value = false
}
}
/** 打开 AI 记账弹窗(组件在打开时自行重置内部状态) */
function handleAiRecordOpen() {
showAiRecordModal.value = true
}
/** 文字记账解析完成后预填新增表单 */
function onAiRecordParsed(r: AiRecordResult) {
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 || [],
}
pendingCategory.value = null
pendingTag.value = null
pendingChannel.value = null
showModal.value = true
if (r.summary) {
message.info(r.summary)
}
}
/** 打开 AI 智能查询弹窗 */
function handleAiQueryOpen() {
if (!aiEnabled.value) {
message.warning(t('ai.needConfig'))
return
}
// 打开时清除上一次可能残留的加载状态,避免按钮一直显示加载中
aiQuerying.value = false
showAiQueryModal.value = true
}
/** 请求 AI 解析自然语言查询 */
async function handleAiQuery(text: string) {
if (!aiEnabled.value) {
message.warning(t('ai.needConfig'))
return
}
if (!text) {
message.warning(t('ai.query.emptyInput'))
return
}
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()
} 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 || ''
const tagIds = [...(r.tagIds || [])]
if (r.untagged) tagIds.push(0)
filterTag.value = 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.untagged
&& (r.channels?.length ?? 0) === 0
&& !r.startTime
&& r.minAmount == null
&& r.maxAmount == null
&& !r.remark
&& r.includeInStats == null
})
/** AI 查询示例(浮窗示例下拉) */
const aiQueryExamples = computed(() => t('ai.query.examples').split('|'))
/** 已激活的筛选条件标签(可单独移除) */
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()
}
/** 打开批量设置分类弹窗:有勾选时作用于勾选记录,否则作用于当前表格展示的记录 */
function handleBatchOpen() {
// 勾选记录均属于当前页,按ID过滤当前页即可得到目标记录
const hasSelection = checkedTransactionIds.value.length > 0
const targets = hasSelection
? transactions.value.filter((tx) => checkedTransactionIds.value.includes(tx.id))
: [...transactions.value]
if (targets.length === 0) {
message.warning(t('transaction.noRecordsToSet'))
return
}
batchTargets.value = targets
batchFromSelection.value = hasSelection
showBatchModal.value = true
}
/** AI 建议应用完成:刷新列表与分类/标签(应用过程中可能新建) */
async function onAiSuggestApplied() {
showAiModal.value = false
await fetchData()
await fetchCategories()
await fetchTags()
}
/** 导出中状态,防止重复点击 */
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()
ensureAiEnabledLoaded()
loadExcludedDisplayStyle()
loadShowHiddenData()
fetchCategories()
fetchTags()
loadChannels()
fetchData()
})
</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="showImportModal = true">{{ t('transaction.importBill') }}</NButton>
<NButton @click="goChannelManage">{{ t('channel.manageEntry') }}</NButton>
<NButton :loading="exporting" @click="handleExport">{{ t('transaction.exportExcel') }}</NButton>
<NButton @click="goStatistics">
<template #icon>
<NIcon :component="StatsChartOutline" />
</template>
{{ t('transaction.viewStatistics') }}
</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;" :wrap="true">
<NButton v-if="!selectionMode" @click="enterSelectionMode">{{ t('transaction.batchMode') }}</NButton>
<NButton v-else @click="exitSelectionMode">{{ t('transaction.exitBatchMode') }}</NButton>
<NButton @click="handleBatchOpen">{{ t('transaction.batchSet') }}</NButton>
<NButton v-if="checkedTransactionIds.length > 0" type="error" @click="handleBatchDelete">
{{ t('transaction.batchDelete') }}
</NButton>
<NButton v-if="checkedTransactionIds.length >= 2" type="primary" ghost @click="handleCreateRelation">
{{ t('transaction.relation.createFromSelection') }}
</NButton>
</NSpace>
<!-- 表格信息栏(右上角) -->
<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"
:scroll-x="1480"
: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="pageSizeOptions"
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; max-width: 92vw;">
<NForm>
<NFormItem :label="t('transaction.column.type')">
<NSelect v-model:value="form.type" :options="typeOptions" />
</NFormItem>
<NFormItem :label="t('transaction.column.category')">
<div class="form-inline">
<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; min-width: 160px;" />
<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>
<NButton style="flex-shrink: 0;" @click="openNewCategory">{{ t('transaction.newCategoryButton') }}</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 class="form-inline">
<NSelect v-model:value="form.channel" :options="channelOptions" clearable :placeholder="t('transaction.channelPlaceholder')" style="flex: 1; min-width: 160px;" />
<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>
<NButton style="flex-shrink: 0;" @click="openNewChannel">{{ t('transaction.newChannelButton') }}</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 class="form-inline">
<NSelect
v-model:value="form.tagIds"
:options="tagFilterOptions"
multiple
clearable
filterable
:placeholder="t('transaction.tagPlaceholder')"
style="flex: 1; min-width: 160px;"
/>
<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>
<NButton style="flex-shrink: 0;" @click="openNewTag">{{ t('transaction.newTagButton') }}</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>
<!-- 手动新建分类弹窗 -->
<NModal v-model:show="showNewCategoryModal" preset="card" :title="t('category.addTitle')" style="width: 450px; max-width: 92vw;">
<NForm>
<NFormItem :label="t('category.name')">
<NInput v-model:value="newCategoryForm.name" :placeholder="t('category.namePlaceholder')" @keyup.enter="handleCreateCategory" />
</NFormItem>
<NFormItem :label="t('category.type')">
<NSelect v-model:value="newCategoryForm.type" :options="typeOptions" />
</NFormItem>
<NFormItem :label="t('category.icon')">
<EmojiPicker
v-model="newCategoryForm.icon"
:placeholder="t('category.iconPlaceholder')"
:input-placeholder="t('category.emojiInputPlaceholder')"
:clear-text="t('category.clearIcon')"
/>
</NFormItem>
<NButton type="primary" block :loading="newCategorySaving" @click="handleCreateCategory">{{ t('common.confirm') }}</NButton>
</NForm>
</NModal>
<!-- 手动新建标签弹窗 -->
<NModal v-model:show="showNewTagModal" preset="card" :title="t('tag.addTitle')" style="width: 450px; max-width: 92vw;">
<NForm>
<NFormItem :label="t('tag.name')">
<NInput v-model:value="newTagForm.name" :placeholder="t('tag.namePlaceholder')" @keyup.enter="handleCreateTag" />
</NFormItem>
<NFormItem :label="t('tag.color')">
<NColorPicker v-model:value="newTagForm.color" :swatches="newTagColorSwatches" :show-alpha="false" />
</NFormItem>
<NButton type="primary" block :loading="newTagSaving" @click="handleCreateTag">{{ t('common.confirm') }}</NButton>
</NForm>
</NModal>
<!-- 手动新建渠道弹窗 -->
<NModal v-model:show="showNewChannelModal" preset="card" :title="t('channel.addTitle')" style="width: 450px; max-width: 92vw;">
<NForm>
<NFormItem :label="t('channel.name')">
<NInput v-model:value="newChannelForm.name" :placeholder="t('channel.namePlaceholder')" @keyup.enter="handleCreateChannel" />
</NFormItem>
<NButton type="primary" block :loading="newChannelSaving" @click="handleCreateChannel">{{ t('common.confirm') }}</NButton>
</NForm>
</NModal>
<!-- 账单导入弹窗 -->
<BillImportModal v-model:show="showImportModal" @imported="fetchData" />
<!-- 账单详情弹窗(含关系与来源图片) -->
<TransactionDetailModal
v-model:show="showDetailModal"
:transaction="detailTransaction"
@changed="fetchData"
@edit="handleDetailEdit"
/>
<!-- 勾选账单建立关系弹窗 -->
<TransactionRelationEditorModal
v-model:show="showRelationEditor"
:editing-group="null"
:initial-members="relationEditorMembers"
:anchor-transaction-id="null"
@saved="onRelationEditorSaved"
/>
<!-- 关系查看弹窗 -->
<TransactionRelationModal
v-model:show="showRelationModal"
:transaction="relationTransaction"
@changed="fetchData"
/>
<!-- 一键设置弹窗(作用于当前表格展示的记录) -->
<TransactionBatchModal
v-model:show="showBatchModal"
v-model:ai-scope="aiScope"
:targets="batchTargets"
:from-selection="batchFromSelection"
:categories="categories"
:tag-options="tagFilterOptions"
:ai-enabled="aiEnabled"
:ai-scope-options="aiScopeOptions"
:ai-loading="aiLoading"
@suggest="handleAiSuggest"
@done="fetchData"
/>
<!-- AI 建议弹窗(一键应用分类与标签建议) -->
<AiSuggestModal
v-if="aiEnabled"
v-model:show="showAiModal"
:suggestions="aiSuggestions"
:transactions="transactions"
:scope="aiScope"
@applied="onAiSuggestApplied"
/>
<!-- AI 智能记账弹窗(文字 / 图片) -->
<AiRecordModal
v-if="aiEnabled"
v-model:show="showAiRecordModal"
:categories="categories"
@parsed="onAiRecordParsed"
@imported="fetchData"
/>
<!-- AI 智能查询浮窗(非模态、可拖拽缩放、多行输入) -->
<AiQueryWindow
v-if="aiEnabled"
v-model:show="showAiQueryModal"
:title="t('ai.query.title')"
:hint="t('ai.query.hint')"
:placeholder="t('ai.query.placeholder')"
:examples="aiQueryExamples"
:loading="aiQuerying"
:history-key="AI_QUERY_HISTORY_KEY"
:storage-key="AI_QUERY_WINDOW_KEY"
@submit="handleAiQuery"
/>
</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;
}
/* 表单行:选择框与按钮组,小屏自动换行避免横向溢出 */
.form-inline {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
flex-wrap: wrap;
}
.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;
}
</style>