feat: 账单导入、统计维度扩展与收支记录增强

- 账单导入:新增支付宝账单解析/导入接口,按 external_no 去重;前端拖拽上传 + 预览确认弹窗
- 分类可选:category_id 允许为空,支持「未分类」筛选,新增按类型一键批量设置分类
- 备注隐藏:新增 remark_hidden 与切换接口,列表默认显示 ****** 并可单格查看/取消隐藏,导出同步隐藏
- 统计扩展:新增 /statistics/summary、/statistics/group,支持日/周/月/季/年/支付方式/分类/类型维度与时间范围过滤
- 收支记录:金额区间过滤、金额/交易时间列排序、分页快速跳转、本页收支结余汇总、交易时间列前置并合并创建时间提示
- 用户偏好:统计面板与列表的筛选条件持久化到 localStorage,进入自动还原
- 其他:新增默认分类种子脚本(含美容美发)、commons-csv 依赖、上传大小限制 10MB、Vite 监听 0.0.0.0
This commit is contained in:
2026-09-17 13:19:59 +08:00
parent 733ed4bb86
commit 1df6e23510
35 changed files with 3016 additions and 1620 deletions
+333 -17
View File
@@ -1,10 +1,12 @@
<script setup lang="ts">
// 收支记录管理组件:展示交易记录列表(分页),支持新增、编辑、删除、多条件筛选
import { ref, onMounted, h } from 'vue'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch } from 'naive-ui'
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 { EyeOutline, EyeOffOutline } from '@vicons/ionicons5'
import type { DataTableColumns } from 'naive-ui'
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats } from '../api/transaction'
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory } from '../api/transaction'
import { getCategoryList } from '../api/category'
import BillImportModal from './BillImportModal.vue'
const message = useMessage() // 消息提示
const dialog = useDialog() // 对话框
@@ -19,6 +21,7 @@ interface TransactionItem {
amount: number
paymentMethod: string
remark: string
remarkHidden: boolean
transactionTime: string
includeInStats: boolean
createdAt: string
@@ -29,6 +32,7 @@ interface CategoryOption {
label: string
value: number
type: string
icon: string
}
// 列表数据和分页状态
@@ -36,10 +40,29 @@ const transactions = ref<TransactionItem[]>([]) // 交易记录列表
const total = ref(0) // 总记录数
const page = ref(1) // 当前页码
const pageSize = ref(20) // 每页条数
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 batchExpenseCategoryId = ref<number | null>(null) // 批量设置的支出分类
const batchIncomeCategoryId = ref<number | null>(null) // 批量设置的收入分类
const batching = ref(false) // 批量设置提交状态
const editingId = ref<number | null>(null) // 当前编辑的记录 ID(null 表示新增)
const categories = ref<CategoryOption[]>([]) // 分类选项列表
const revealedRemarkIds = ref<number[]>([]) // 临时展开查看原文的备注行 ID(不持久,刷新/翻页后重置)
// 表单数据(新增/编辑共用)
const form = ref({
@@ -72,6 +95,24 @@ 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 categoryFilterOptions = computed(() => [
{ label: '未分类', value: 0, type: '' },
...categories.value.filter(c => filterType.value.length === 0 || filterType.value.includes(c.type)),
])
/** 将分类筛选拆分为分类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 = [
@@ -79,6 +120,55 @@ const includeInStatsOptions = [
{ 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
} 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,
}))
} catch (e) {
// 忽略写入失败
}
}
// 筛选条件变化时自动保存用户偏好
watch(
[filterType, filterCategory, filterPaymentMethod, filterDateRange, filterIncludeInStats, filterRemark, filterMinAmount, filterMaxAmount, pageSize],
saveFilterPrefs,
{ deep: true },
)
/** 将时间戳格式化为 ISO 格式的日期时间字符串(YYYY-MM-DDTHH:mm:ss) */
function formatTimestamp(ts: number, isEnd = false): string {
const d = new Date(ts)
@@ -94,9 +184,64 @@ function getPaymentLabel(val: string) {
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 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,
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)
return h('span', { title: `创建时间:${created || '-'}` }, time)
},
},
{
title: '类型', key: 'type', width: 90,
render(row) {
@@ -106,9 +251,16 @@ function createColumns(): DataTableColumns<TransactionItem> {
return h(NTag, { type: row.type === 'INCOME' ? 'success' : 'error', size: 'small' }, { default: () => row.type === 'INCOME' ? '收入' : '支出' })
},
},
{ title: '分类', key: 'categoryName', width: 100 },
{
title: '分类', key: 'categoryName', width: 120,
render(row) {
const name = row.categoryName || '未分类'
return row.categoryIcon ? `${row.categoryIcon} ${name}` : name
},
},
{
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)}`)
@@ -121,14 +273,28 @@ function createColumns(): DataTableColumns<TransactionItem> {
title: '支付方式', key: 'paymentMethod', width: 100,
render(row) { return getPaymentLabel(row.paymentMethod) },
},
{ title: '备注', key: 'remark', ellipsis: { tooltip: true } },
{
title: '交易时间', key: 'transactionTime', width: 170,
render(row) { return row.transactionTime?.replace('T', ' ').substring(0, 16) },
},
{
title: '创建时间', key: 'createdAt', width: 170,
render(row) { return row.createdAt?.replace('T', ' ').substring(0, 16) },
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: 'includeInStats', width: 100, align: 'center',
@@ -159,7 +325,7 @@ 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 }))
categories.value = res.data.map((c: any) => ({ label: c.name, value: c.id, type: c.type, icon: c.icon || '' }))
} catch (e) {
// 静默处理
}
@@ -173,14 +339,18 @@ async function fetchData() {
page: page.value,
size: pageSize.value,
types: filterType.value.length > 0 ? filterType.value : undefined,
categoryIds: filterCategory.value.length > 0 ? filterCategory.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,
})
transactions.value = res.data.records
total.value = res.data.total
revealedRemarkIds.value = []
} catch (e: any) {
message.error(e.message || '加载失败')
} finally {
@@ -269,6 +439,13 @@ function handlePageChange(p: number) {
fetchData()
}
/** 每页条数变化处理:重置到第一页并重新加载 */
function handlePageSizeChange(size: number) {
pageSize.value = size
page.value = 1
fetchData()
}
/** 执行查询:重置页码并按当前筛选条件加载数据 */
function handleSearch() {
page.value = 1
@@ -282,6 +459,9 @@ function handleReset() {
filterPaymentMethod.value = []
filterDateRange.value = null
filterIncludeInStats.value = null
filterRemark.value = ''
filterMinAmount.value = null
filterMaxAmount.value = null
page.value = 1
fetchData()
}
@@ -297,16 +477,53 @@ async function handleToggleStats(row: TransactionItem, value: boolean) {
}
}
/** 打开批量设置分类弹窗 */
function handleBatchOpen() {
if (transactions.value.length === 0) {
message.warning('当前表格没有可设置的记录')
return
}
batchExpenseCategoryId.value = null
batchIncomeCategoryId.value = null
showBatchModal.value = true
}
/** 对当前表格展示的记录按类型批量设置分类 */
async function handleBatchSubmit() {
if (!batchExpenseCategoryId.value && !batchIncomeCategoryId.value) {
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} 条记录`)
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,
categoryIds: filterCategory.value.length > 0 ? filterCategory.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,
})
const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = window.URL.createObjectURL(blob)
@@ -325,6 +542,7 @@ async function handleExport() {
// 组件挂载时加载分类和交易记录
onMounted(() => {
loadFilterPrefs()
fetchCategories()
fetchData()
})
@@ -336,6 +554,8 @@ onMounted(() => {
<NSpace justify="space-between" style="margin-bottom: 16px;">
<h2 style="margin: 0;">收支记录</h2>
<NSpace>
<NButton @click="handleBatchOpen">一键设置分类</NButton>
<NButton @click="showImportModal = true">导入账单</NButton>
<NButton @click="handleExport">导出 Excel</NButton>
<NButton type="primary" @click="handleAdd">新增记录</NButton>
</NSpace>
@@ -355,7 +575,7 @@ onMounted(() => {
/>
<NSelect
v-model:value="filterCategory"
:options="categories.filter(c => filterType.length === 0 || filterType.includes(c.type))"
:options="categoryFilterOptions"
multiple
clearable
filterable
@@ -385,17 +605,64 @@ onMounted(() => {
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
placeholder="备注搜索"
style="width: 180px;"
@keyup.enter="handleSearch"
/>
</NSpace>
<NSpace justify="end" style="margin-top: 12px;">
<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" :page-count="Math.ceil(total / pageSize)" @update:page="handlePageChange" />
<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>
<!-- 新增/编辑弹窗 -->
@@ -405,7 +672,7 @@ onMounted(() => {
<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)" placeholder="请选择分类" filterable />
<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="请输入金额" />
@@ -426,5 +693,54 @@ onMounted(() => {
<NButton type="primary" block @click="handleSubmit">确定</NButton>
</NForm>
</NModal>
<!-- 账单导入弹窗 -->
<BillImportModal v-model:show="showImportModal" @imported="fetchData" />
<!-- 一键设置分类弹窗(作用于当前表格展示的记录) -->
<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>
<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;
}
</style>