Files
ledgerly/frontend/src/views/TransactionList.vue
T
rosercode 65c0d8c550 feat: 新增标签体系、微信账单导入与收支记录体验优化
- 标签:新增 t_tag / t_transaction_tag 表与标签 CRUD,支持交易打标签、按标签筛选、批量添加/移除标签、导入账单默认标签,新增标签管理页
- 账单导入:新增微信支付 xlsx 解析器(WechatBillParser),支持支付宝 / 微信按类型导入;解析行保存原始完整数据(raw_data)并可在列表查看
- 收支记录:排序移至顶部并改为服务端排序(交易时间 / 金额,记忆偏好);筛选条件收纳为「更多筛选」弹层;分类筛选显示图标;一键设置支持「计入统计」;导入预览支持关键字 / 类型 / 状态 / 金额筛选
- 数据库:init.sql 新增 raw_data 字段与标签表,新增 init_tags.sql 默认标签种子
- 其他:更新 README 测试账号
2026-09-17 16:47:26 +08:00

950 lines
34 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 { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover } from 'naive-ui'
import { EyeOutline, EyeOffOutline } from '@vicons/ionicons5'
import type { DataTableColumns } from 'naive-ui'
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags } from '../api/transaction'
import { getCategoryList } from '../api/category'
import { getTagList } from '../api/tag'
import BillImportModal from './BillImportModal.vue'
const message = useMessage() // 消息提示
const dialog = useDialog() // 对话框
/** 交易记录数据结构 */
interface TransactionItem {
id: number
categoryId: number
categoryName: string
categoryIcon: string
type: string // INCOME 或 EXPENSE
amount: number
paymentMethod: string
remark: string
remarkHidden: boolean
transactionTime: string
includeInStats: boolean
rawData: string | null
tags: TagOption[]
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 = ref(20) // 每页条数
const sortValue = ref('transactionTime-desc') // 排序方式:排序字段-方向
const sortOptions = [
{ label: '交易时间(新→旧)', value: 'transactionTime-desc' },
{ label: '交易时间(旧→新)', value: 'transactionTime-asc' },
{ label: '金额(高→低)', value: 'amount-desc' },
{ label: '金额(低→高)', 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 t of transactions.value) {
const amount = Number(t.amount) || 0
if (t.type === 'INCOME') income += amount
else expense += amount
}
return { income, expense, balance: income - expense }
})
const loading = ref(false) // 列表加载状态
const showModal = ref(false) // 新增/编辑弹窗显示状态
const showImportModal = ref(false) // 账单导入弹窗显示状态
const showBatchModal = 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 batchStatsOptions = [
{ label: '不修改', value: 'null' },
{ label: '计入统计', value: 'true' },
{ label: '不计入统计', value: 'false' },
]
const editingId = ref<number | null>(null) // 当前编辑的记录 ID(null 表示新增)
const categories = ref<CategoryOption[]>([]) // 分类选项列表
const allTags = ref<TagOption[]>([]) // 全部标签(用于筛选、编辑、批量)
const revealedRemarkIds = ref<number[]>([]) // 临时展开查看原文的备注行 ID(不持久,刷新/翻页后重置)
// 表单数据(新增/编辑共用)
const form = ref({
categoryId: null as number | null,
type: 'EXPENSE',
amount: null as number | null,
paymentMethod: '',
remark: '',
transactionTime: Date.now(),
includeInStats: true,
tagIds: [] as number[],
})
// 类型选项
const typeOptions = [
{ label: '支出', value: 'EXPENSE' },
{ label: '收入', value: 'INCOME' },
]
// 支付方式选项
const paymentOptions = [
{ label: '微信', value: 'WECHAT' },
{ label: '支付宝', value: 'ALIPAY' },
{ label: '现金', value: 'CASH' },
{ label: '银行卡', value: 'BANK' },
]
// 筛选条件状态
const filterType = ref<string[]>([]) // 类型筛选(多选)
const filterCategory = ref<number[]>([]) // 分类筛选(多选)
const filterPaymentMethod = ref<string[]>([]) // 支付方式筛选(多选)
const filterDateRange = ref<[number, number] | null>(null) // 日期范围筛选(起止时间戳)
const filterIncludeInStats = ref<string | null>(null) // 计入统计筛选:null-全部,'true'-计入,'false'-不计入
const filterRemark = ref('') // 备注关键字搜索
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 (filterPaymentMethod.value.length > 0) count++
if (filterTag.value.length > 0) count++
if (filterIncludeInStats.value !== null) count++
if (filterMinAmount.value !== null || filterMaxAmount.value !== null) count++
return count
})
/** 清空「更多筛选」中的条件 */
function clearMoreFilters() {
filterPaymentMethod.value = []
filterTag.value = []
filterIncludeInStats.value = null
filterMinAmount.value = null
filterMaxAmount.value = null
}
// 分类筛选项:固定包含「未分类」,其余分类按类型筛选联动
const categoryFilterOptions = computed(() => [
{ label: '未分类', value: 0, type: '' },
...categories.value.filter(c => filterType.value.length === 0 || filterType.value.includes(c.type)),
])
// 标签筛选项
const tagFilterOptions = computed(() => allTags.value.map((t) => ({ label: t.name, value: t.id })))
/** 将分类筛选拆分为分类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 = [
{ label: '计入', value: 'true' },
{ label: '不计入', 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
if (Array.isArray(p.filterPaymentMethod)) filterPaymentMethod.value = p.filterPaymentMethod
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.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,
filterPaymentMethod: filterPaymentMethod.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, filterPaymentMethod, filterDateRange, filterIncludeInStats, filterRemark, filterMinAmount, filterMaxAmount, pageSize, sortValue, filterTag],
saveFilterPrefs,
{ deep: true },
)
/** 将时间戳格式化为 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}`
}
/** 将支付方式编码转换为中文标签 */
function getPaymentLabel(val: string) {
const map: Record<string, string> = { WECHAT: '微信', ALIPAY: '支付宝', CASH: '现金', BANK: '银行卡' }
return map[val] || val || '-'
}
/** 分类下拉项渲染:图标 + 名称(下拉选项与已选项均生效) */
function renderCategoryLabel(option: any) {
return h('span', null, [
option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null,
option.label,
])
}
/** 分类已选项(多选标签)渲染:图标 + 名称 */
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 ? '已隐藏备注' : '已取消隐藏')
} catch (e: any) {
message.error(e.message || '操作失败')
}
}
/** 创建表格列配置 */
function createColumns(): DataTableColumns<TransactionItem> {
return [
{
title: '交易时间', 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: `创建时间:${created || '-'}` }, time)
},
},
{
title: '类型', key: 'type', width: 90,
render(row) {
if (!row.includeInStats) {
return h(NTag, { type: 'default', size: 'small' }, { default: () => '不计入' })
}
return h(NTag, { type: row.type === 'INCOME' ? 'success' : 'error', size: 'small' }, { default: () => row.type === 'INCOME' ? '收入' : '支出' })
},
},
{
title: '分类', key: 'categoryName', width: 120,
render(row) {
const name = row.categoryName || '未分类'
return row.categoryIcon ? `${row.categoryIcon} ${name}` : name
},
},
{
title: '金额', key: 'amount', width: 120,
render(row) {
if (!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: '支付方式', key: 'paymentMethod', width: 100,
render(row) { return getPaymentLabel(row.paymentMethod) },
},
{
title: '备注', 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 ? '备注已隐藏' : (row.remark || ''),
}, collapsed ? '******' : (row.remark || '-')),
]
if (collapsed) {
nodes.push(renderRemarkAction('查看', EyeOutline, () => revealRemark(row.id)))
nodes.push(renderRemarkAction('取消隐藏', EyeOffOutline, () => setRemarkHidden(row, false)))
} else if (hidden) {
nodes.push(renderRemarkAction('收起', EyeOffOutline, () => concealRemark(row.id)))
nodes.push(renderRemarkAction('取消隐藏', EyeOutline, () => setRemarkHidden(row, false)))
} else {
nodes.push(renderRemarkAction('隐藏', EyeOffOutline, () => setRemarkHidden(row, true)))
}
return h('div', { class: 'remark-cell' }, nodes)
},
},
{
title: '标签', 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: '计入统计', key: 'includeInStats', width: 100, align: 'center',
render(row) {
return h(NSwitch, {
value: row.includeInStats,
onUpdateValue: (v: boolean) => handleToggleStats(row, v),
})
},
},
{
title: '操作', key: 'actions', width: 240,
render(row) {
return h(NSpace, null, {
default: () => [
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => '编辑' }),
h(NButton, { size: 'small', disabled: !row.rawData, onClick: () => handleViewRaw(row) }, { default: () => '原始数据' }),
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => '删除' }),
],
})
},
},
]
}
const columns = createColumns() // 表格列配置
/** 加载分类列表 */
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(),
paymentMethods: filterPaymentMethod.value.length > 0 ? filterPaymentMethod.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,
tagIds: filterTag.value.length > 0 ? filterTag.value : undefined,
})
transactions.value = res.data.records
total.value = res.data.total
revealedRemarkIds.value = []
} catch (e: any) {
message.error(e.message || '加载失败')
} finally {
loading.value = false
}
}
// 排序方式变化时重置到第一页并重新查询
watch(sortValue, () => {
page.value = 1
fetchData()
})
/** 查看某条记录的原始账单数据(格式化为缩进 JSON) */
function handleViewRaw(row: TransactionItem) {
if (!row.rawData) {
message.warning('该记录没有原始账单数据')
return
}
try {
rawDataText.value = JSON.stringify(JSON.parse(row.rawData), null, 2)
} catch (e) {
rawDataText.value = row.rawData
}
showRawModal.value = true
}
/** 打开新增记录弹窗,重置表单 */
function handleAdd() {
editingId.value = null
form.value = { categoryId: null, type: 'EXPENSE', amount: null, paymentMethod: '', remark: '', transactionTime: Date.now(), includeInStats: true, tagIds: [] }
showModal.value = true
}
/** 打开编辑记录弹窗,填充表单数据 */
function handleEdit(row: TransactionItem) {
editingId.value = row.id
form.value = {
categoryId: row.categoryId,
type: row.type,
amount: row.amount,
paymentMethod: row.paymentMethod || '',
remark: row.remark || '',
transactionTime: new Date(row.transactionTime).getTime(),
includeInStats: row.includeInStats ?? true,
tagIds: (row.tags || []).map((t) => t.id),
}
showModal.value = true
}
/** 提交表单:校验 -> 新增或更新 -> 刷新列表 */
async function handleSubmit() {
if (!form.value.categoryId) {
message.warning('请选择分类')
return
}
if (!form.value.amount || form.value.amount <= 0) {
message.warning('请输入有效金额')
return
}
const data = {
categoryId: form.value.categoryId!,
type: form.value.type,
amount: form.value.amount!,
paymentMethod: form.value.paymentMethod || undefined,
remark: form.value.remark || undefined,
transactionTime: new Date(form.value.transactionTime).toISOString().slice(0, 19),
includeInStats: form.value.includeInStats,
tagIds: form.value.tagIds,
}
try {
if (editingId.value) {
await updateTransaction(editingId.value, data)
message.success('修改成功')
} else {
await createTransaction(data)
message.success('创建成功')
}
showModal.value = false
await fetchData()
} catch (e: any) {
message.error(e.message || '操作失败')
}
}
/** 删除记录:弹出确认对话框,确认后执行删除并刷新列表 */
function handleDelete(id: number) {
dialog.warning({
title: '确认删除',
content: '确定要删除这条记录吗?',
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await deleteTransaction(id)
message.success('删除成功')
await fetchData()
} catch (e: any) {
message.error(e.message || '删除失败')
}
},
})
}
/** 翻页事件处理 */
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 = []
filterPaymentMethod.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 ? '已计入统计' : '已排除统计')
} catch (e: any) {
message.error(e.message || '操作失败')
}
}
/** 打开批量设置分类弹窗 */
function handleBatchOpen() {
if (transactions.value.length === 0) {
message.warning('当前表格没有可设置的记录')
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('请至少选择分类、计入统计或标签')
return
}
batching.value = true
try {
const ids = transactions.value.map((t) => t.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(`已更新 ${ids.length} 条记录`)
showBatchModal.value = false
await fetchData()
} catch (e: any) {
message.error(e.message || '操作失败')
} finally {
batching.value = false
}
}
/** 导出交易记录为 Excel 文件 */
async function handleExport() {
try {
const res = await exportTransaction({
types: filterType.value.length > 0 ? filterType.value : undefined,
...buildCategoryFilter(),
paymentMethods: filterPaymentMethod.value.length > 0 ? filterPaymentMethod.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,
tagIds: filterTag.value.length > 0 ? filterTag.value : undefined,
})
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 = `交易记录_${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('导出成功')
} catch (e: any) {
message.error(e.message || '导出失败')
}
}
// 组件挂载时加载分类和交易记录
onMounted(() => {
loadFilterPrefs()
fetchCategories()
fetchTags()
fetchData()
})
</script>
<template>
<div>
<!-- 顶部:标题和新增按钮 -->
<NSpace justify="space-between" style="margin-bottom: 16px;">
<h2 style="margin: 0;">收支记录</h2>
<NSpace align="center">
<span style="color: #999; font-size: 13px;">排序:</span>
<NSelect v-model:value="sortValue" :options="sortOptions" style="width: 170px;" />
<NButton @click="handleBatchOpen">一键设置</NButton>
<NButton @click="showImportModal = true">导入账单</NButton>
<NButton @click="handleExport">导出 Excel</NButton>
<NButton type="primary" @click="handleAdd">新增记录</NButton>
</NSpace>
</NSpace>
<!-- 筛选条件区域:常用条件常驻,其余收进「更多筛选」 -->
<NCard :bordered="false" style="margin-bottom: 16px;">
<NSpace align="center" :wrap="true" :size="[12, 12]">
<NSelect
v-model:value="filterType"
:options="typeOptions"
multiple
clearable
:max-tag-count="2"
placeholder="类型"
style="width: 160px;"
/>
<NSelect
v-model:value="filterCategory"
:options="categoryFilterOptions"
:render-label="renderCategoryLabel"
:render-tag="renderCategoryTag"
multiple
clearable
filterable
:max-tag-count="2"
placeholder="分类"
style="width: 200px;"
/>
<NDatePicker
v-model:value="filterDateRange"
type="daterange"
clearable
style="width: 260px;"
/>
<NInput
v-model:value="filterRemark"
clearable
placeholder="备注搜索"
style="width: 180px;"
@keyup.enter="handleSearch"
/>
<NPopover v-model:show="showMoreFilter" trigger="click" placement="bottom-start">
<template #trigger>
<NButton>
更多筛选
<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">支付方式</div>
<NSelect
v-model:value="filterPaymentMethod"
:options="paymentOptions"
multiple
clearable
:max-tag-count="2"
placeholder="全部"
style="width: 100%;"
/>
</div>
<div>
<div class="filter-label">标签</div>
<NSelect
v-model:value="filterTag"
:options="tagFilterOptions"
multiple
clearable
filterable
:max-tag-count="2"
placeholder="全部"
style="width: 100%;"
/>
</div>
<div>
<div class="filter-label">计入统计</div>
<NSelect
v-model:value="filterIncludeInStats"
:options="includeInStatsOptions"
clearable
placeholder="全部"
style="width: 100%;"
/>
</div>
<div>
<div class="filter-label">金额区间</div>
<NSpace align="center" :size="8">
<NInputNumber
v-model:value="filterMinAmount"
:min="0"
:precision="2"
clearable
placeholder="最小"
style="width: 110px;"
/>
<span style="color: #999;">~</span>
<NInputNumber
v-model:value="filterMaxAmount"
:min="0"
:precision="2"
clearable
placeholder="最大"
style="width: 110px;"
/>
</NSpace>
</div>
<NSpace justify="end">
<NButton size="small" @click="clearMoreFilters">清空</NButton>
</NSpace>
</NSpace>
</NPopover>
<NButton type="primary" @click="handleSearch">查询</NButton>
<NButton @click="handleReset">重置</NButton>
</NSpace>
</NCard>
<!-- 表格信息栏(右上角) -->
<NSpace justify="end" align="center" :size="12" style="margin-bottom: 8px;">
<span style="color: #999; font-size: 12px;">
总计 {{ total }} 条 · 第 {{ page }} / {{ totalPages }} 页 · 每页 {{ pageSize }} 条
</span>
<span style="font-size: 12px; color: #999;">
本页支出 <span style="color: #d03050;">{{ pageSummary.expense.toFixed(2) }}</span>
· 本页收入 <span style="color: #18a058;">{{ pageSummary.income.toFixed(2) }}</span>
· 本页结余 <span :style="{ color: pageSummary.balance < 0 ? '#d03050' : '#18a058' }">{{ pageSummary.balance.toFixed(2) }}</span>
</span>
</NSpace>
<!-- 交易记录数据表格 -->
<NDataTable :columns="columns" :data="transactions" :loading="loading" :bordered="false" />
<!-- 分页控件 -->
<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 ? '编辑记录' : '新增记录'" style="width: 500px;">
<NForm>
<NFormItem label="类型">
<NSelect v-model:value="form.type" :options="typeOptions" />
</NFormItem>
<NFormItem label="分类">
<NSelect v-model:value="form.categoryId" :options="categories.filter(c => c.type === form.type)" :render-label="renderCategoryLabel" placeholder="请选择分类" filterable />
</NFormItem>
<NFormItem label="金额">
<NInputNumber v-model:value="form.amount" :min="0.01" :precision="2" style="width: 100%;" placeholder="请输入金额" />
</NFormItem>
<NFormItem label="支付方式">
<NSelect v-model:value="form.paymentMethod" :options="paymentOptions" clearable placeholder="选择支付方式" />
</NFormItem>
<NFormItem label="交易时间">
<NDatePicker v-model:value="form.transactionTime" type="datetime" style="width: 100%;" />
</NFormItem>
<NFormItem label="备注">
<NInput v-model:value="form.remark" type="textarea" placeholder="可选备注说明" />
</NFormItem>
<NFormItem label="标签">
<NSelect
v-model:value="form.tagIds"
:options="tagFilterOptions"
multiple
clearable
filterable
placeholder="选择标签"
/>
</NFormItem>
<NFormItem label="计入统计">
<NSwitch v-model:value="form.includeInStats" />
<span style="color: #999; margin-left: 8px; font-size: 12px;">关闭后不纳入月度统计(如借款、转账等)</span>
</NFormItem>
<NButton type="primary" block @click="handleSubmit">确定</NButton>
</NForm>
</NModal>
<!-- 账单导入弹窗 -->
<BillImportModal v-model:show="showImportModal" @imported="fetchData" />
<!-- 原始账单数据弹窗 -->
<NModal v-model:show="showRawModal" preset="card" title="原始账单数据" style="width: 640px;">
<NInput :value="rawDataText" type="textarea" readonly :autosize="{ minRows: 8, maxRows: 20 }" />
</NModal>
<!-- 一键设置弹窗(作用于当前表格展示的记录) -->
<NModal v-model:show="showBatchModal" preset="card" title="一键设置分类与统计" style="width: 480px;">
<NForm>
<NFormItem label="作用范围">
<span style="color: #999; font-size: 12px;">
将对当前表格展示的 {{ transactions.length }} 条记录设置分类与计入统计
</span>
</NFormItem>
<NFormItem label="支出分类">
<NSelect
v-model:value="batchExpenseCategoryId"
:options="categories.filter(c => c.type === 'EXPENSE')"
:render-label="renderCategoryLabel"
filterable
clearable
placeholder="不设置支出分类"
/>
</NFormItem>
<NFormItem label="收入分类">
<NSelect
v-model:value="batchIncomeCategoryId"
:options="categories.filter(c => c.type === 'INCOME')"
:render-label="renderCategoryLabel"
filterable
clearable
placeholder="不设置收入分类"
/>
</NFormItem>
<NFormItem label="计入统计">
<NSelect v-model:value="batchIncludeInStats" :options="batchStatsOptions" />
</NFormItem>
<NFormItem label="添加标签">
<NSelect v-model:value="batchAddTagIds" :options="tagFilterOptions" multiple clearable filterable placeholder="不添加标签" />
</NFormItem>
<NFormItem label="移除标签">
<NSelect v-model:value="batchRemoveTagIds" :options="tagFilterOptions" multiple clearable filterable placeholder="不移除标签" />
</NFormItem>
<NButton type="primary" block :loading="batching" @click="handleBatchSubmit">确定</NButton>
</NForm>
</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;
}
</style>