feat: 新增标签体系、微信账单导入与收支记录体验优化

- 标签:新增 t_tag / t_transaction_tag 表与标签 CRUD,支持交易打标签、按标签筛选、批量添加/移除标签、导入账单默认标签,新增标签管理页
- 账单导入:新增微信支付 xlsx 解析器(WechatBillParser),支持支付宝 / 微信按类型导入;解析行保存原始完整数据(raw_data)并可在列表查看
- 收支记录:排序移至顶部并改为服务端排序(交易时间 / 金额,记忆偏好);筛选条件收纳为「更多筛选」弹层;分类筛选显示图标;一键设置支持「计入统计」;导入预览支持关键字 / 类型 / 状态 / 金额筛选
- 数据库:init.sql 新增 raw_data 字段与标签表,新增 init_tags.sql 默认标签种子
- 其他:更新 README 测试账号
This commit is contained in:
2026-09-17 16:47:26 +08:00
parent 1df6e23510
commit 65c0d8c550
33 changed files with 1854 additions and 100 deletions
+260 -57
View File
@@ -1,11 +1,12 @@
<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 } from 'naive-ui'
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 } from '../api/transaction'
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() // 消息提示
@@ -24,6 +25,8 @@ interface TransactionItem {
remarkHidden: boolean
transactionTime: string
includeInStats: boolean
rawData: string | null
tags: TagOption[]
createdAt: string
}
@@ -35,11 +38,27 @@ interface CategoryOption {
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))) // 总页数
// 当前页金额汇总:支出、收入与结余(含本页所有展示记录)
@@ -57,11 +76,24 @@ 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(不持久,刷新/翻页后重置)
// 表单数据(新增/编辑共用)
@@ -73,6 +105,7 @@ const form = ref({
remark: '',
transactionTime: Date.now(),
includeInStats: true,
tagIds: [] as number[],
})
// 类型选项
@@ -98,6 +131,27 @@ const filterIncludeInStats = ref<string | null>(null) // 计入统计筛选:nu
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(() => [
@@ -105,6 +159,9 @@ const categoryFilterOptions = computed(() => [
...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)
@@ -138,6 +195,8 @@ function loadFilterPrefs() {
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) {
// 忽略损坏的偏好数据
}
@@ -156,6 +215,8 @@ function saveFilterPrefs() {
filterMinAmount: filterMinAmount.value,
filterMaxAmount: filterMaxAmount.value,
pageSize: pageSize.value,
sortValue: sortValue.value,
filterTag: filterTag.value,
}))
} catch (e) {
// 忽略写入失败
@@ -164,7 +225,7 @@ function saveFilterPrefs() {
// 筛选条件变化时自动保存用户偏好
watch(
[filterType, filterCategory, filterPaymentMethod, filterDateRange, filterIncludeInStats, filterRemark, filterMinAmount, filterMaxAmount, pageSize],
[filterType, filterCategory, filterPaymentMethod, filterDateRange, filterIncludeInStats, filterRemark, filterMinAmount, filterMaxAmount, pageSize, sortValue, filterTag],
saveFilterPrefs,
{ deep: true },
)
@@ -192,6 +253,14 @@ function renderCategoryLabel(option: any) {
])
}
/** 分类已选项(多选标签)渲染:图标 + 名称 */
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, {
@@ -234,8 +303,6 @@ function createColumns(): DataTableColumns<TransactionItem> {
return [
{
title: '交易时间', key: 'transactionTime', width: 170,
sorter: (a, b) => (a.transactionTime || '').localeCompare(b.transactionTime || ''),
defaultSortOrder: 'descend',
render(row) {
const time = row.transactionTime?.replace('T', ' ').substring(0, 16) || '-'
const created = row.createdAt?.replace('T', ' ').substring(0, 16)
@@ -260,7 +327,6 @@ function createColumns(): DataTableColumns<TransactionItem> {
},
{
title: '金额', key: 'amount', width: 120,
sorter: (a, b) => a.amount - b.amount,
render(row) {
if (!row.includeInStats) {
return h('span', { style: { color: '#999', fontWeight: 'bold' } }, `${row.amount.toFixed(2)}`)
@@ -296,6 +362,16 @@ function createColumns(): DataTableColumns<TransactionItem> {
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) {
@@ -306,11 +382,12 @@ function createColumns(): DataTableColumns<TransactionItem> {
},
},
{
title: '操作', key: 'actions', width: 160,
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: () => '删除' }),
],
})
@@ -331,6 +408,16 @@ async function fetchCategories() {
}
}
/** 加载标签列表 */
async function fetchTags() {
try {
const res: any = await getTagList()
allTags.value = res.data || []
} catch (e) {
// 静默处理
}
}
/** 加载交易记录列表(分页),携带所有筛选条件 */
async function fetchData() {
loading.value = true
@@ -347,6 +434,9 @@ async function fetchData() {
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
@@ -358,10 +448,30 @@ async function fetchData() {
}
}
// 排序方式变化时重置到第一页并重新查询
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 }
form.value = { categoryId: null, type: 'EXPENSE', amount: null, paymentMethod: '', remark: '', transactionTime: Date.now(), includeInStats: true, tagIds: [] }
showModal.value = true
}
@@ -376,6 +486,7 @@ function handleEdit(row: TransactionItem) {
remark: row.remark || '',
transactionTime: new Date(row.transactionTime).getTime(),
includeInStats: row.includeInStats ?? true,
tagIds: (row.tags || []).map((t) => t.id),
}
showModal.value = true
}
@@ -398,6 +509,7 @@ async function handleSubmit() {
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) {
@@ -462,6 +574,7 @@ function handleReset() {
filterRemark.value = ''
filterMinAmount.value = null
filterMaxAmount.value = null
filterTag.value = []
page.value = 1
fetchData()
}
@@ -485,23 +598,39 @@ function handleBatchOpen() {
}
batchExpenseCategoryId.value = null
batchIncomeCategoryId.value = null
batchIncludeInStats.value = 'null'
batchAddTagIds.value = []
batchRemoveTagIds.value = []
showBatchModal.value = true
}
/** 对当前表格展示的记录按类型批量设置分类 */
async function handleBatchSubmit() {
if (!batchExpenseCategoryId.value && !batchIncomeCategoryId.value) {
message.warning('请至少选择一个分类')
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 res: any = await batchUpdateCategory({
ids: transactions.value.map((t) => t.id),
expenseCategoryId: batchExpenseCategoryId.value,
incomeCategoryId: batchIncomeCategoryId.value,
})
message.success(`已更新 ${res.data} 条记录`)
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) {
@@ -524,6 +653,9 @@ async function handleExport() {
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)
@@ -544,6 +676,7 @@ async function handleExport() {
onMounted(() => {
loadFilterPrefs()
fetchCategories()
fetchTags()
fetchData()
})
</script>
@@ -553,15 +686,17 @@ onMounted(() => {
<!-- 顶部:标题和新增按钮 -->
<NSpace justify="space-between" style="margin-bottom: 16px;">
<h2 style="margin: 0;">收支记录</h2>
<NSpace>
<NButton @click="handleBatchOpen">一键设置分类</NButton>
<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
@@ -576,6 +711,8 @@ onMounted(() => {
<NSelect
v-model:value="filterCategory"
:options="categoryFilterOptions"
:render-label="renderCategoryLabel"
:render-tag="renderCategoryTag"
multiple
clearable
filterable
@@ -583,45 +720,12 @@ onMounted(() => {
placeholder="分类"
style="width: 200px;"
/>
<NSelect
v-model:value="filterPaymentMethod"
:options="paymentOptions"
multiple
clearable
:max-tag-count="2"
placeholder="支付方式"
style="width: 200px;"
/>
<NDatePicker
v-model:value="filterDateRange"
type="daterange"
clearable
style="width: 260px;"
/>
<NSelect
v-model:value="filterIncludeInStats"
:options="includeInStatsOptions"
clearable
placeholder="计入统计"
style="width: 130px;"
/>
<NInputNumber
v-model:value="filterMinAmount"
:min="0"
:precision="2"
clearable
placeholder="最小金额"
style="width: 130px;"
/>
<span style="color: #999;">~</span>
<NInputNumber
v-model:value="filterMaxAmount"
:min="0"
:precision="2"
clearable
placeholder="最大金额"
style="width: 130px;"
/>
<NInput
v-model:value="filterRemark"
clearable
@@ -629,8 +733,78 @@ onMounted(() => {
style="width: 180px;"
@keyup.enter="handleSearch"
/>
</NSpace>
<NSpace justify="end" style="margin-top: 12px;">
<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>
@@ -686,6 +860,16 @@ onMounted(() => {
<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>
@@ -697,12 +881,17 @@ onMounted(() => {
<!-- 账单导入弹窗 -->
<BillImportModal v-model:show="showImportModal" @imported="fetchData" />
<!-- 一键设置分类弹窗(作用于当前表格展示的记录) -->
<NModal v-model:show="showBatchModal" preset="card" title="一键设置分类" style="width: 480px;">
<!-- 原始账单数据弹窗 -->
<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 }} 条记录按类型设置分类
将对当前表格展示的 {{ transactions.length }} 条记录设置分类与计入统计
</span>
</NFormItem>
<NFormItem label="支出分类">
@@ -725,6 +914,15 @@ onMounted(() => {
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>
@@ -743,4 +941,9 @@ onMounted(() => {
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-label {
margin-bottom: 4px;
color: #999;
font-size: 12px;
}
</style>