feat: 初始化收支管理系统

This commit is contained in:
wangshuo
2026-08-30 14:36:22 +08:00
commit 733ed4bb86
79 changed files with 5808 additions and 0 deletions
+430
View File
@@ -0,0 +1,430 @@
<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 type { DataTableColumns } from 'naive-ui'
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats } from '../api/transaction'
import { getCategoryList } from '../api/category'
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
transactionTime: string
includeInStats: boolean
createdAt: string
}
/** 分类选项数据结构 */
interface CategoryOption {
label: string
value: number
type: string
}
// 列表数据和分页状态
const transactions = ref<TransactionItem[]>([]) // 交易记录列表
const total = ref(0) // 总记录数
const page = ref(1) // 当前页码
const pageSize = ref(20) // 每页条数
const loading = ref(false) // 列表加载状态
const showModal = ref(false) // 新增/编辑弹窗显示状态
const editingId = ref<number | null>(null) // 当前编辑的记录 ID(null 表示新增)
const categories = ref<CategoryOption[]>([]) // 分类选项列表
// 表单数据(新增/编辑共用)
const form = ref({
categoryId: null as number | null,
type: 'EXPENSE',
amount: null as number | null,
paymentMethod: '',
remark: '',
transactionTime: Date.now(),
includeInStats: true,
})
// 类型选项
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 includeInStatsOptions = [
{ label: '计入', value: 'true' },
{ label: '不计入', value: 'false' },
]
/** 将时间戳格式化为 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 createColumns(): DataTableColumns<TransactionItem> {
return [
{
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: 100 },
{
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', 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: 'includeInStats', width: 100, align: 'center',
render(row) {
return h(NSwitch, {
value: row.includeInStats,
onUpdateValue: (v: boolean) => handleToggleStats(row, v),
})
},
},
{
title: '操作', key: 'actions', width: 160,
render(row) {
return h(NSpace, null, {
default: () => [
h(NButton, { size: 'small', onClick: () => handleEdit(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 }))
} 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,
categoryIds: filterCategory.value.length > 0 ? filterCategory.value : undefined,
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',
})
transactions.value = res.data.records
total.value = res.data.total
} catch (e: any) {
message.error(e.message || '加载失败')
} finally {
loading.value = false
}
}
/** 打开新增记录弹窗,重置表单 */
function handleAdd() {
editingId.value = null
form.value = { categoryId: null, type: 'EXPENSE', amount: null, paymentMethod: '', remark: '', transactionTime: Date.now(), includeInStats: true }
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,
}
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,
}
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 handleSearch() {
page.value = 1
fetchData()
}
/** 重置所有筛选条件并重新查询 */
function handleReset() {
filterType.value = []
filterCategory.value = []
filterPaymentMethod.value = []
filterDateRange.value = null
filterIncludeInStats.value = null
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 || '操作失败')
}
}
/** 导出交易记录为 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,
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',
})
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(() => {
fetchCategories()
fetchData()
})
</script>
<template>
<div>
<!-- 顶部:标题和新增按钮 -->
<NSpace justify="space-between" style="margin-bottom: 16px;">
<h2 style="margin: 0;">收支记录</h2>
<NSpace>
<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="categories.filter(c => filterType.length === 0 || filterType.includes(c.type))"
multiple
clearable
filterable
:max-tag-count="2"
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;"
/>
<NButton type="primary" @click="handleSearch">查询</NButton>
<NButton @click="handleReset">重置</NButton>
</NSpace>
</NCard>
<!-- 交易记录数据表格 -->
<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" />
</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)" 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="计入统计">
<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>
</div>
</template>