- 标签:新增 t_tag / t_transaction_tag 表与标签 CRUD,支持交易打标签、按标签筛选、批量添加/移除标签、导入账单默认标签,新增标签管理页 - 账单导入:新增微信支付 xlsx 解析器(WechatBillParser),支持支付宝 / 微信按类型导入;解析行保存原始完整数据(raw_data)并可在列表查看 - 收支记录:排序移至顶部并改为服务端排序(交易时间 / 金额,记忆偏好);筛选条件收纳为「更多筛选」弹层;分类筛选显示图标;一键设置支持「计入统计」;导入预览支持关键字 / 类型 / 状态 / 金额筛选 - 数据库:init.sql 新增 raw_data 字段与标签表,新增 init_tags.sql 默认标签种子 - 其他:更新 README 测试账号
950 lines
34 KiB
Vue
950 lines
34 KiB
Vue
<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>
|