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
+1
View File
@@ -35,6 +35,7 @@ dependencies {
implementation 'com.github.xiaoymin:knife4j-openapi3-jakarta-spring-boot-starter:4.5.0'
implementation 'com.alibaba:easyexcel:4.0.3'
implementation 'org.apache.commons:commons-csv:1.12.0'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
-1432
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -15,3 +15,26 @@ export function getCategoryStatistics(params: { year: number; month: number }) {
export function getTrendStatistics(params: { year: number; month: number }) {
return request.get('/statistics/trend', { params })
}
/** 统计过滤条件,供概览与分组统计复用 */
export interface StatisticsFilterParams {
startTime?: string
endTime?: string
types?: string[]
categoryIds?: number[]
uncategorized?: boolean
paymentMethods?: string[]
minAmount?: number
maxAmount?: number
remark?: string
}
/** 获取指定时间范围的统计概览(收支汇总、日均、最大单笔、平均每笔) */
export function getStatisticsSummary(params: StatisticsFilterParams) {
return request.get('/statistics/summary', { params })
}
/** 按维度获取分组统计数据(dimension:day/week/month/quarter/year/paymentMethod/category/type) */
export function getStatisticsGroup(params: StatisticsFilterParams & { dimension: string }) {
return request.get('/statistics/group', { params })
}
+73 -1
View File
@@ -3,16 +3,20 @@ import request from './request'
import axios from 'axios'
import { useUserStore } from '../store/user'
/** 分页获取交易记录列表,支持按类型、分类、支付方式、时间范围、计入统计多选筛选 */
/** 分页获取交易记录列表,支持按类型、分类、支付方式、时间范围、计入统计、金额区间多选筛选 */
export function getTransactionPage(params: {
page?: number
size?: number
types?: string[]
categoryIds?: number[]
uncategorized?: boolean
paymentMethods?: string[]
startTime?: string
endTime?: string
includeInStats?: boolean
minAmount?: number
maxAmount?: number
remark?: string
}) {
return request.get('/transaction/page', { params })
}
@@ -53,14 +57,82 @@ export function toggleTransactionStats(id: number, includeInStats: boolean) {
return request.put(`/transaction/${id}/stats`, { includeInStats })
}
/** 切换指定交易记录的备注隐藏标记 */
export function toggleRemarkHidden(id: number, remarkHidden: boolean) {
return request.put(`/transaction/${id}/remark-hidden`, { remarkHidden })
}
/** 支持的账单类型,当前仅支付宝 */
export type BillType = 'ALIPAY'
/** 账单解析结果行 */
export interface ParsedBillRow {
rowNo: number
externalNo: string
transactionTime: string
type: string
amount: number
counterparty: string
product: string
tradeStatus: string
direction: string
includeInStats: boolean
importable: boolean
selected: boolean
duplicate: boolean
error: string | null
}
/** 待导入账单行 */
export interface BillImportItem {
externalNo: string
transactionTime: string
type: string
amount: number
counterparty: string
product: string
includeInStats: boolean
}
/** 解析账单文件(不落库),返回预览数据 */
export function parseBill(file: File, billType: BillType) {
const formData = new FormData()
formData.append('file', file)
formData.append('billType', billType)
return request.post('/transaction/import/parse', formData)
}
/** 确认导入用户勾选的账单行 */
export function confirmImport(data: {
billType: BillType
expenseCategoryId?: number | null
incomeCategoryId?: number | null
items: BillImportItem[]
}) {
return request.post('/transaction/import/confirm', data)
}
/** 批量设置交易记录的分类(按类型分别应用支出/收入分类) */
export function batchUpdateCategory(data: {
ids: number[]
expenseCategoryId?: number | null
incomeCategoryId?: number | null
}) {
return request.put('/transaction/batch-category', data)
}
/** 导出交易记录为 Excel 文件,支持按条件筛选 */
export function exportTransaction(params: {
types?: string[]
categoryIds?: number[]
uncategorized?: boolean
paymentMethods?: string[]
startTime?: string
endTime?: string
includeInStats?: boolean
minAmount?: number
maxAmount?: number
remark?: string
}) {
const userStore = useUserStore()
return axios.get('/api/transaction/export', {
+356
View File
@@ -0,0 +1,356 @@
<script setup lang="ts">
// 账单导入弹窗:先解析、后勾选导入(默认全选),当前支持支付宝账单
import { ref, computed, h, watch } from 'vue'
import {
useMessage, NModal, NSpace, NButton, NSelect, NUpload, NUploadDragger, NDataTable,
NCheckbox, NTag, NAlert, NText, NIcon,
} from 'naive-ui'
import type { DataTableColumns, UploadFileInfo } from 'naive-ui'
import { CloudUploadOutline } from '@vicons/ionicons5'
import { parseBill, confirmImport } from '../api/transaction'
import type { BillType, ParsedBillRow, BillImportItem } from '../api/transaction'
import { getCategoryList } from '../api/category'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
(e: 'imported'): void
}>()
const message = useMessage()
// 账单类型选项,当前仅支持支付宝
const billType = ref<BillType>('ALIPAY')
const billTypeOptions = [{ label: '支付宝', value: 'ALIPAY' }]
// 弹窗阶段:选择文件 -> 预览勾选 -> 导入结果
const stage = ref<'select' | 'preview' | 'result'>('select')
const file = ref<File | null>(null)
const uploadFileList = ref<UploadFileInfo[]>([])
const parsing = ref(false)
const importing = ref(false)
// 解析结果与勾选状态
const rows = ref<ParsedBillRow[]>([])
const checkedRowNos = ref<number[]>([])
// 默认分类
const expenseCategories = ref<{ label: string; value: number }[]>([])
const incomeCategories = ref<{ label: string; value: number }[]>([])
const expenseCategoryId = ref<number | null>(null)
const incomeCategoryId = ref<number | null>(null)
// 导入结果
const result = ref<{ total: number; imported: number; skipped: number; failed: number; messages: string[] } | null>(null)
/** 可导入的行 */
const selectableRows = computed(() => rows.value.filter((r) => r.importable))
/** 已勾选的行 */
const checkedRows = computed(() => rows.value.filter((r) => checkedRowNos.value.includes(r.rowNo)))
/** 是否全选 */
const allChecked = computed(
() => selectableRows.value.length > 0 && selectableRows.value.every((r) => checkedRowNos.value.includes(r.rowNo)),
)
/** 是否存在部分勾选 */
const someChecked = computed(() => checkedRowNos.value.length > 0 && !allChecked.value)
/** 表格列定义 */
const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
{
title: () =>
h(NCheckbox, {
checked: allChecked.value,
indeterminate: someChecked.value,
disabled: selectableRows.value.length === 0,
onUpdateChecked: (v: boolean) => toggleAll(v),
}),
key: 'checked',
width: 50,
render: (row) =>
h(NCheckbox, {
checked: checkedRowNos.value.includes(row.rowNo),
disabled: !row.importable,
onUpdateChecked: (v: boolean) => toggleRow(row, v),
}),
},
{
title: '交易时间',
key: 'transactionTime',
width: 150,
render: (row) => (row.transactionTime ? row.transactionTime.replace('T', ' ').substring(0, 16) : '-'),
},
{
title: '类型',
key: 'type',
width: 90,
render: (row) => {
if (!row.importable) {
return h(NTag, { type: 'error', size: 'small' }, { default: () => '不可导入' })
}
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: 'amount',
width: 110,
render: (row) => {
const color = row.type === 'INCOME' ? '#18a058' : '#d03050'
return h('span', { style: { color, fontWeight: 'bold' } }, `${row.type === 'INCOME' ? '+' : '-'}${Number(row.amount).toFixed(2)}`)
},
},
{ title: '交易对方', key: 'counterparty', width: 160, ellipsis: { tooltip: true } },
{ title: '商品名称', key: 'product', ellipsis: { tooltip: true } },
{
title: '状态',
key: 'tradeStatus',
width: 150,
render: (row) => {
if (row.error) {
return h(NTag, { type: 'error', size: 'small' }, { default: () => row.error })
}
if (row.duplicate) {
return h(NTag, { type: 'warning', size: 'small' }, { default: () => '已存在' })
}
return h('span', null, row.tradeStatus || '-')
},
},
])
/** 加载分类选项 */
async function loadCategories() {
try {
const [expenseRes, incomeRes]: any = await Promise.all([getCategoryList('EXPENSE'), getCategoryList('INCOME')])
expenseCategories.value = (expenseRes.data || []).map((c: any) => ({ label: c.name, value: c.id }))
incomeCategories.value = (incomeRes.data || []).map((c: any) => ({ label: c.name, value: c.id }))
} catch (e: any) {
message.error(e.message || '加载分类失败')
}
}
/** 文件选择变化(支持点击或拖拽) */
function handleFileChange(options: { fileList: UploadFileInfo[] }) {
uploadFileList.value = options.fileList
const last = options.fileList[options.fileList.length - 1]
file.value = last?.file ?? null
}
/** 解析账单 */
async function handleParse() {
if (!file.value) {
message.warning('请先选择账单文件')
return
}
parsing.value = true
try {
const res: any = await parseBill(file.value, billType.value)
rows.value = res.data.rows || []
// 默认勾选解析器建议勾选的行
checkedRowNos.value = rows.value.filter((r) => r.selected).map((r) => r.rowNo)
stage.value = 'preview'
} catch (e: any) {
message.error(e.message || '解析失败')
} finally {
parsing.value = false
}
}
/** 切换单行勾选 */
function toggleRow(row: ParsedBillRow, checked: boolean) {
if (checked) {
if (!checkedRowNos.value.includes(row.rowNo)) {
checkedRowNos.value = [...checkedRowNos.value, row.rowNo]
}
} else {
checkedRowNos.value = checkedRowNos.value.filter((n) => n !== row.rowNo)
}
}
/** 全选/取消全选 */
function toggleAll(checked: boolean) {
checkedRowNos.value = checked ? selectableRows.value.map((r) => r.rowNo) : []
}
/** 确认导入 */
async function handleConfirm() {
if (checkedRows.value.length === 0) {
message.warning('请至少勾选一条记录')
return
}
const items: BillImportItem[] = checkedRows.value.map((r) => ({
externalNo: r.externalNo,
transactionTime: r.transactionTime,
type: r.type,
amount: r.amount,
counterparty: r.counterparty,
product: r.product,
includeInStats: r.includeInStats,
}))
importing.value = true
try {
const res: any = await confirmImport({
billType: billType.value,
expenseCategoryId: expenseCategoryId.value,
incomeCategoryId: incomeCategoryId.value,
items,
})
result.value = res.data
stage.value = 'result'
emit('imported')
} catch (e: any) {
message.error(e.message || '导入失败')
} finally {
importing.value = false
}
}
/** 重置弹窗状态 */
function reset() {
stage.value = 'select'
file.value = null
uploadFileList.value = []
rows.value = []
checkedRowNos.value = []
result.value = null
expenseCategoryId.value = null
incomeCategoryId.value = null
}
/** 关闭弹窗 */
function handleClose() {
emit('update:show', false)
}
// 打开弹窗时重置并加载分类
watch(
() => props.show,
(visible) => {
if (visible) {
reset()
loadCategories()
}
},
)
</script>
<template>
<NModal
:show="props.show"
preset="card"
title="导入账单"
style="width: 960px; max-width: 92vw;"
:mask-closable="false"
@update:show="handleClose"
>
<!-- 第一步:选择账单类型与文件 -->
<template v-if="stage === 'select'">
<NSpace vertical :size="16">
<NSpace align="center">
<NText>账单类型:</NText>
<NSelect v-model:value="billType" :options="billTypeOptions" style="width: 160px;" />
</NSpace>
<div>
<NText>账单文件:</NText>
<NUpload
:file-list="uploadFileList"
:default-upload="false"
:max="1"
accept=".csv"
style="margin-top: 8px;"
@change="handleFileChange"
>
<NUploadDragger>
<div style="padding: 24px 0; text-align: center;">
<NIcon size="48" :depth="3">
<CloudUploadOutline />
</NIcon>
<div style="font-size: 15px; margin-top: 8px;">点击或拖拽文件到此处</div>
<div style="font-size: 12px; color: #999; margin-top: 4px;">
仅支持支付宝导出的交易记录明细 .csv 文件
</div>
</div>
</NUploadDragger>
</NUpload>
</div>
<NAlert type="info" :show-icon="true">
导出路径:支付宝 App → 我的 → 账单 → 开具交易流水证明 / 账单导出,选择 CSV 格式。
</NAlert>
<NSpace justify="end">
<NButton @click="handleClose">取消</NButton>
<NButton type="primary" :loading="parsing" :disabled="!file" @click="handleParse">解析</NButton>
</NSpace>
</NSpace>
</template>
<!-- 第二步:预览并勾选 -->
<template v-else-if="stage === 'preview'">
<NSpace vertical :size="12">
<NSpace align="center" :wrap="true" :size="[12, 12]">
<NText>支出默认分类(可不选):</NText>
<NSelect
v-model:value="expenseCategoryId"
:options="expenseCategories"
filterable
clearable
placeholder="未选则不计分类"
style="width: 180px;"
/>
<NText>收入默认分类(可不选):</NText>
<NSelect
v-model:value="incomeCategoryId"
:options="incomeCategories"
filterable
clearable
placeholder="未选则不计分类"
style="width: 180px;"
/>
<NText depth="3">共 {{ rows.length }} 条,已勾选 {{ checkedRows.length }} 条</NText>
</NSpace>
<NDataTable
:columns="columns"
:data="rows"
:max-height="420"
:row-key="(row) => row.rowNo"
size="small"
:bordered="false"
/>
<NSpace justify="end">
<NButton @click="stage = 'select'">上一步</NButton>
<NButton type="primary" :loading="importing" @click="handleConfirm">
确认导入({{ checkedRows.length }})
</NButton>
</NSpace>
</NSpace>
</template>
<!-- 第三步:导入结果 -->
<template v-else>
<NSpace vertical :size="12">
<NAlert type="success" :show-icon="true">
导入完成:成功 {{ result?.imported ?? 0 }} 条,跳过 {{ result?.skipped ?? 0 }} 条,失败 {{ result?.failed ?? 0 }} 条
</NAlert>
<div v-if="result?.messages?.length" style="max-height: 320px; overflow: auto;">
<NText v-for="(msg, i) in result.messages" :key="i" depth="3" style="display: block; font-size: 12px;">
{{ msg }}
</NText>
</div>
<NSpace justify="end">
<NButton type="primary" @click="handleClose">完成</NButton>
</NSpace>
</NSpace>
</template>
</NModal>
</template>
+410 -51
View File
@@ -1,34 +1,183 @@
<script setup lang="ts">
// 统计面板组件:展示月度收支汇总、分类占比饼图、每日收支趋势柱状图
import { ref, onMounted, watch } from 'vue'
import { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect } from 'naive-ui'
// 统计面板组件:展示指定时间范围的收支汇总、概览指标、趋势、分类占比与支付方式分布
import { ref, computed, onMounted, watch, nextTick, h } from 'vue'
import { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect, NDatePicker, NInput, NInputNumber, NButton } from 'naive-ui'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, PieChart, BarChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
import { getMonthlyStatistics, getCategoryStatistics, getTrendStatistics } from '../api/statistics'
import { getStatisticsSummary, getStatisticsGroup } from '../api/statistics'
import { getCategoryList } from '../api/category'
// 注册 ECharts 所需组件
use([CanvasRenderer, LineChart, PieChart, BarChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
const now = new Date()
const selectedYear = ref(now.getFullYear()) // 选中的年份
const selectedMonth = ref(now.getMonth() + 1) // 选中的月份
// 时间范围快捷选项
const rangeOptions = [
{ label: '本月', value: 'month' },
{ label: '近3月', value: '3month' },
{ label: '近12月', value: '12month' },
{ label: '自定义', value: 'custom' },
]
// 趋势图时间粒度选项
const granularityOptions = [
{ label: '按日', value: 'day' },
{ label: '按周', value: 'week' },
{ label: '按月', value: 'month' },
{ label: '按季', value: 'quarter' },
{ label: '按年', value: 'year' },
]
// 月度汇总数据(总收入、总支出、结余、交易笔数)
const monthly = ref({ totalIncome: 0, totalExpense: 0, balance: 0, transactionCount: 0 })
const categoryData = ref<any[]>([]) // 分类统计数据
const trendData = ref<any[]>([]) // 每日趋势数据
const rangeType = ref<'month' | '3month' | '12month' | 'custom'>('month') // 时间范围类型
const customRange = ref<[number, number] | null>(null) // 自定义起止时间戳
const granularity = ref<'day' | 'week' | 'month' | 'quarter' | 'year'>('day') // 趋势粒度
// 年份和月份下拉选项
const yearOptions = Array.from({ length: 10 }, (_, i) => ({ label: `${2020 + i}年`, value: 2020 + i }))
const monthOptions = Array.from({ length: 12 }, (_, i) => ({ label: `${i + 1}月`, value: i + 1 }))
// 过滤条件
const filterType = ref<string[]>([]) // 类型(多选)
const filterCategory = ref<number[]>([]) // 分类(多选,0 表示未分类)
const filterPaymentMethod = ref<string[]>([]) // 支付方式(多选)
const filterMinAmount = ref<number | null>(null) // 最小金额
const filterMaxAmount = ref<number | null>(null) // 最大金额
const filterRemark = ref('') // 备注关键字
const pieOption = ref({}) // 饼图配置
const trendOption = ref({}) // 趋势图配置
// 类型与支付方式选项
const typeOptions = [
{ label: '支出', value: 'EXPENSE' },
{ label: '收入', value: 'INCOME' },
]
const paymentOptions = [
{ label: '微信', value: 'WECHAT' },
{ label: '支付宝', value: 'ALIPAY' },
{ label: '现金', value: 'CASH' },
{ label: '银行卡', value: 'BANK' },
]
/** 更新支出分类饼图配置 */
/** 分类选项数据结构 */
interface CategoryOption {
label: string
value: number
type: string
icon: string
}
const categories = ref<CategoryOption[]>([]) // 分类选项列表
// 分类筛选项:固定包含「未分类」,其余分类按类型筛选联动
const categoryFilterOptions = computed(() => [
{ label: '未分类', value: 0, type: '' },
...categories.value.filter(c => filterType.value.length === 0 || filterType.value.includes(c.type)),
])
/** 分类下拉项渲染:图标 + 名称 */
function renderCategoryLabel(option: any) {
return h('span', null, [
option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null,
option.label,
])
}
/** 将分类筛选拆分为分类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 filterParams = computed(() => ({
types: filterType.value.length > 0 ? filterType.value : undefined,
...buildCategoryFilter(),
paymentMethods: filterPaymentMethod.value.length > 0 ? filterPaymentMethod.value : undefined,
minAmount: filterMinAmount.value ?? undefined,
maxAmount: filterMaxAmount.value ?? undefined,
remark: filterRemark.value.trim() || undefined,
}))
// 统计结果
const summary = ref<any>({
totalIncome: 0, totalExpense: 0, balance: 0, transactionCount: 0,
dayCount: 0, avgDailyIncome: 0, avgDailyExpense: 0,
maxExpenseAmount: 0, maxExpenseTime: null, maxExpenseRemark: '', avgExpenseAmount: 0,
})
const trendData = ref<any[]>([]) // 趋势分组数据
const categoryData = ref<any[]>([]) // 分类分组数据
const paymentData = ref<any[]>([]) // 支付方式分组数据
const pieOption = ref({}) // 支出分类饼图
const paymentOption = ref({}) // 支付方式饼图
const trendOption = ref({}) // 趋势柱状图
const pad = (n: number) => String(n).padStart(2, '0')
/** 格式化日期为查询用字符串 */
function fmt(d: Date, end = false): string {
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
return end ? `${date}T23:59:59` : `${date}T00:00:00`
}
/** 根据快捷选项与自定义区间计算查询起止时间 */
const range = computed(() => {
if (rangeType.value === 'custom' && customRange.value) {
return {
startTime: fmt(new Date(customRange.value[0])),
endTime: fmt(new Date(customRange.value[1]), true),
}
}
const now = new Date()
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0) // 本月最后一天
let start: Date
if (rangeType.value === '12month') {
start = new Date(now.getFullYear(), now.getMonth() - 11, 1)
} else if (rangeType.value === '3month') {
start = new Date(now.getFullYear(), now.getMonth() - 2, 1)
} else {
start = new Date(now.getFullYear(), now.getMonth(), 1)
}
return { startTime: fmt(start), endTime: fmt(end, true) }
})
/** 计算 ISO 周标签,与后端 %x-W%v 对齐 */
function isoWeekKey(d: Date): string {
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
const dayNum = date.getUTCDay() || 7
date.setUTCDate(date.getUTCDate() + 4 - dayNum)
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1))
const weekNo = Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7)
return `${date.getUTCFullYear()}-W${pad(weekNo)}`
}
/** 按粒度生成区间内完整的分组键序列(用于补全无数据的点) */
function buildKeys(start: Date, end: Date, gran: string): string[] {
const keys: string[] = []
if (gran === 'day') {
for (const d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
keys.push(`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`)
}
} else if (gran === 'week') {
const seen = new Set<string>()
for (const d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const k = isoWeekKey(d)
if (!seen.has(k)) { seen.add(k); keys.push(k) }
}
} else if (gran === 'month') {
for (const d = new Date(start.getFullYear(), start.getMonth(), 1); d <= end; d.setMonth(d.getMonth() + 1)) {
keys.push(`${d.getFullYear()}-${pad(d.getMonth() + 1)}`)
}
} else if (gran === 'quarter') {
const seen = new Set<string>()
for (const d = new Date(start.getFullYear(), start.getMonth(), 1); d <= end; d.setMonth(d.getMonth() + 1)) {
const k = `${d.getFullYear()}-Q${Math.floor(d.getMonth() / 3) + 1}`
if (!seen.has(k)) { seen.add(k); keys.push(k) }
}
} else {
for (let y = start.getFullYear(); y <= end.getFullYear(); y++) keys.push(String(y))
}
return keys
}
/** 更新支出分类饼图 */
function updatePieChart() {
pieOption.value = {
title: { text: '支出分类占比', left: 'center' },
@@ -36,98 +185,308 @@ function updatePieChart() {
series: [
{
type: 'pie',
radius: ['40%', '70%'], // 环形图内外半径
radius: ['40%', '70%'],
data: categoryData.value
.filter((c: any) => c.type === 'EXPENSE') // 只展示支出类
.map((c: any) => ({ name: c.categoryName, value: c.amount })),
.filter((c: any) => c.type === 'EXPENSE')
.map((c: any) => ({ name: c.name, value: Number(c.total) })),
},
],
}
}
/** 更新每日收支趋势柱状图配置 */
/** 更新支付方式分布饼图 */
function updatePaymentChart() {
paymentOption.value = {
title: { text: '支付方式分布', left: 'center' },
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
series: [
{
type: 'pie',
radius: ['40%', '70%'],
data: paymentData.value.map((p: any) => ({ name: p.name, value: Number(p.total) })),
},
],
}
}
/** 更新每日/周/月收支趋势柱状图(补全缺失点) */
function updateTrendChart() {
const { startTime, endTime } = range.value
const start = new Date(startTime.replace('T', ' ').replace(/-/g, '/'))
const end = new Date(endTime.replace('T', ' ').replace(/-/g, '/'))
const keys = buildKeys(start, end, granularity.value)
const map = new Map<string, any>(trendData.value.map((d: any) => [d.key, d]))
const income = keys.map((k) => Number(map.get(k)?.income ?? 0))
const expense = keys.map((k) => Number(map.get(k)?.expense ?? 0))
const labels = keys.map((k) => (granularity.value === 'day' ? k.substring(5) : k))
trendOption.value = {
title: { text: '每日收支趋势' },
tooltip: { trigger: 'axis' },
legend: { data: ['收入', '支出'], bottom: 0 },
grid: { left: 60, right: 20, top: 40, bottom: 40 },
xAxis: {
type: 'category',
data: trendData.value.map((d: any) => d.date.substring(5)), // 只显示 MM-DD
},
xAxis: { type: 'category', data: labels },
yAxis: { type: 'value' },
series: [
{ name: '收入', type: 'bar', data: trendData.value.map((d: any) => d.income), color: '#18a058' },
{ name: '支出', type: 'bar', data: trendData.value.map((d: any) => d.expense), color: '#d03050' },
{ name: '收入', type: 'bar', data: income, color: '#18a058' },
{ name: '支出', type: 'bar', data: expense, color: '#d03050' },
],
}
}
/** 并行请求所有统计数据并更新图表 */
async function fetchData() {
const params = { year: selectedYear.value, month: selectedMonth.value }
const { startTime, endTime } = range.value
const filters = filterParams.value
try {
const [monthlyRes, categoryRes, trendRes]: any[] = await Promise.all([
getMonthlyStatistics(params),
getCategoryStatistics(params),
getTrendStatistics(params),
const [summaryRes, trendRes, categoryRes, paymentRes]: any[] = await Promise.all([
getStatisticsSummary({ startTime, endTime, ...filters }),
getStatisticsGroup({ dimension: granularity.value, startTime, endTime, ...filters }),
getStatisticsGroup({ dimension: 'category', startTime, endTime, ...filters }),
getStatisticsGroup({ dimension: 'paymentMethod', startTime, endTime, ...filters }),
])
monthly.value = monthlyRes.data
categoryData.value = categoryRes.data
summary.value = summaryRes.data
trendData.value = trendRes.data
categoryData.value = categoryRes.data
paymentData.value = paymentRes.data
updatePieChart()
updatePaymentChart()
updateTrendChart()
} catch (e: any) {
console.error('加载统计数据失败', e)
}
}
// 监听年月变化,自动重新加载数据
watch([selectedYear, selectedMonth], fetchData)
onMounted(fetchData) // 组件挂载时加载数据
/** 面板偏好存储键 */
const DASHBOARD_PREFS_KEY = 'finance.dashboard.prefs'
let suppressRangeWatch = false // 还原偏好时跳过 rangeType 对粒度的默认覆盖
/** 读取面板偏好并还原(时间范围、粒度、过滤条件) */
function loadPrefs() {
try {
const raw = localStorage.getItem(DASHBOARD_PREFS_KEY)
if (!raw) return
const p = JSON.parse(raw)
suppressRangeWatch = true
if (['month', '3month', '12month', 'custom'].includes(p.rangeType)) rangeType.value = p.rangeType
if (Array.isArray(p.customRange) && p.customRange.length === 2) customRange.value = p.customRange
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 (typeof p.filterMinAmount === 'number') filterMinAmount.value = p.filterMinAmount
if (typeof p.filterMaxAmount === 'number') filterMaxAmount.value = p.filterMaxAmount
if (typeof p.filterRemark === 'string') filterRemark.value = p.filterRemark
if (['day', 'week', 'month', 'quarter', 'year'].includes(p.granularity)) granularity.value = p.granularity
} catch (e) {
// 忽略损坏的偏好数据
}
}
/** 保存面板偏好,下次进入自动还原 */
function savePrefs() {
try {
localStorage.setItem(DASHBOARD_PREFS_KEY, JSON.stringify({
rangeType: rangeType.value,
customRange: customRange.value,
granularity: granularity.value,
filterType: filterType.value,
filterCategory: filterCategory.value,
filterPaymentMethod: filterPaymentMethod.value,
filterMinAmount: filterMinAmount.value,
filterMaxAmount: filterMaxAmount.value,
filterRemark: filterRemark.value,
}))
} catch (e) {
// 忽略写入失败
}
}
/** 加载分类选项 */
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) {
// 静默处理
}
}
/** 重置所有过滤条件(保留时间范围与粒度) */
function handleResetFilters() {
filterType.value = []
filterCategory.value = []
filterPaymentMethod.value = []
filterMinAmount.value = null
filterMaxAmount.value = null
filterRemark.value = ''
}
// 切换时间范围时给出默认粒度(还原偏好时不覆盖)
watch(rangeType, (v) => {
if (suppressRangeWatch) {
suppressRangeWatch = false
return
}
granularity.value = v === '12month' ? 'month' : v === '3month' ? 'week' : 'day'
})
// 范围、粒度或过滤条件变化时重新加载
watch([() => range.value.startTime, () => range.value.endTime, granularity, filterParams], fetchData)
// 任一偏好变化时自动保存
watch([rangeType, customRange, granularity, filterType, filterCategory, filterPaymentMethod, filterMinAmount, filterMaxAmount, filterRemark], savePrefs, { deep: true })
onMounted(async () => {
loadPrefs()
fetchCategories()
await nextTick()
suppressRangeWatch = false
fetchData()
})
</script>
<template>
<div>
<!-- 顶部:标题和年月选择器 -->
<NSpace justify="space-between" align="center" style="margin-bottom: 20px;">
<!-- 顶部:标题与时间范围选择 -->
<NSpace justify="space-between" align="center" style="margin-bottom: 20px;" :wrap="true">
<h2 style="margin: 0;">统计面板</h2>
<NSpace>
<NSelect v-model:value="selectedYear" :options="yearOptions" style="width: 100px;" />
<NSelect v-model:value="selectedMonth" :options="monthOptions" style="width: 80px;" />
<NSpace align="center" :wrap="true">
<NSelect v-model:value="rangeType" :options="rangeOptions" style="width: 110px;" />
<NDatePicker
v-if="rangeType === 'custom'"
v-model:value="customRange"
type="daterange"
clearable
style="width: 260px;"
/>
<span style="color: #999; font-size: 12px;">{{ range.startTime.substring(0, 10) }} ~ {{ range.endTime.substring(0, 10) }}</span>
</NSpace>
</NSpace>
<!-- 统计卡片:总收入、总支出、结余、交易笔数 -->
<NGrid :cols="4" :x-gap="16" :y-gap="16" style="margin-bottom: 20px;">
<!-- 过滤条件(偏好自动记忆) -->
<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"
multiple
clearable
filterable
:max-tag-count="2"
:render-label="renderCategoryLabel"
placeholder="分类"
style="width: 200px;"
/>
<NSelect
v-model:value="filterPaymentMethod"
:options="paymentOptions"
multiple
clearable
:max-tag-count="2"
placeholder="支付方式"
style="width: 200px;"
/>
<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;"
/>
<NButton @click="handleResetFilters">重置筛选</NButton>
</NSpace>
</NCard>
<!-- 汇总卡片 -->
<NGrid :cols="4" :x-gap="16" :y-gap="16" style="margin-bottom: 16px;">
<NGridItem>
<NCard>
<NStatistic label="总收入" :value="Number(monthly.totalIncome).toFixed(2)" style="color: #18a058;">
<NStatistic label="总收入" :value="Number(summary.totalIncome).toFixed(2)" style="color: #18a058;">
<template #prefix>+</template>
</NStatistic>
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<NStatistic label="总支出" :value="Number(monthly.totalExpense).toFixed(2)" style="color: #d03050;">
<NStatistic label="总支出" :value="Number(summary.totalExpense).toFixed(2)" style="color: #d03050;">
<template #prefix>-</template>
</NStatistic>
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<NStatistic label="结余" :value="Number(monthly.balance).toFixed(2)" />
<NStatistic label="结余" :value="Number(summary.balance).toFixed(2)" />
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<NStatistic label="交易笔数" :value="monthly.transactionCount" />
<NStatistic label="交易笔数" :value="summary.transactionCount" />
</NCard>
</NGridItem>
</NGrid>
<!-- 图表区域:饼图 + 趋势柱状图 -->
<!-- 概览指标 -->
<NGrid :cols="4" :x-gap="16" :y-gap="16" style="margin-bottom: 20px;">
<NGridItem>
<NCard>
<NStatistic label="日均收入" :value="Number(summary.avgDailyIncome).toFixed(2)" />
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<NStatistic label="日均支出" :value="Number(summary.avgDailyExpense).toFixed(2)" />
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<NStatistic label="最大单笔支出" :value="Number(summary.maxExpenseAmount).toFixed(2)">
<template #suffix>
<span style="font-size: 12px; color: #999;">{{ (summary.maxExpenseRemark || '').slice(0, 12) }}</span>
</template>
</NStatistic>
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<NStatistic label="平均每笔支出" :value="Number(summary.avgExpenseAmount).toFixed(2)" />
</NCard>
</NGridItem>
</NGrid>
<!-- 趋势图 -->
<NCard style="margin-bottom: 16px;">
<template #header>
<NSpace justify="space-between" align="center">
<span>收支趋势</span>
<NSelect v-model:value="granularity" :options="granularityOptions" style="width: 110px;" size="small" />
</NSpace>
</template>
<VChart :option="trendOption" style="height: 350px;" autoresize />
</NCard>
<!-- 饼图区域:支出分类 + 支付方式 -->
<NGrid :cols="2" :x-gap="16" :y-gap="16">
<NGridItem>
<NCard>
@@ -136,7 +495,7 @@ onMounted(fetchData) // 组件挂载时加载数据
</NGridItem>
<NGridItem>
<NCard>
<VChart :option="trendOption" style="height: 350px;" autoresize />
<VChart :option="paymentOption" style="height: 350px;" autoresize />
</NCard>
</NGridItem>
</NGrid>
+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>
+1
View File
@@ -4,6 +4,7 @@ import { defineConfig } from 'vite'
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
@@ -0,0 +1,91 @@
package com.example.finance.controller;
import com.example.finance.common.BusinessException;
import com.example.finance.common.Result;
import com.example.finance.dto.BillImportRequest;
import com.example.finance.enums.BillType;
import com.example.finance.service.BillImportService;
import com.example.finance.vo.BillImportResultVO;
import com.example.finance.vo.BillParseResultVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
/**
* 账单导入控制器
*
* <p>提供「先解析、后导入」两阶段接口:解析返回预览数据,用户勾选后再确认导入。</p>
*
* @date 2026-09-17 09:50:21
*/
@RestController
@RequestMapping("/api/transaction/import")
@Tag(name = "账单导入", description = "第三方账单(如支付宝)解析与导入接口")
public class BillImportController {
private final BillImportService billImportService;
public BillImportController(BillImportService billImportService) {
this.billImportService = billImportService;
}
/**
* 解析账单文件(不落库)
*
* @param request HTTP请求,从中获取用户ID
* @param file 账单文件
* @param billType 账单类型,如 ALIPAY
* @return 解析预览结果
* @throws IOException 文件读取失败
*/
@PostMapping("/parse")
@Operation(summary = "解析账单文件", description = "解析账单文件并返回预览数据,不写入数据库")
public Result<BillParseResultVO> parse(HttpServletRequest request,
@Parameter(description = "账单文件") @RequestParam("file") MultipartFile file,
@Parameter(description = "账单类型,如 ALIPAY") @RequestParam("billType") String billType) throws IOException {
Long userId = (Long) request.getAttribute("userId");
if (file == null || file.isEmpty()) {
throw new BusinessException("请选择账单文件");
}
return Result.success(billImportService.parse(userId, resolveBillType(billType), file.getBytes()));
}
/**
* 确认导入用户勾选的账单行
*
* @param request HTTP请求,从中获取用户ID
* @param body 导入请求(默认分类与勾选行)
* @return 导入结果统计
*/
@PostMapping("/confirm")
@Operation(summary = "确认导入账单", description = "将用户勾选的账单行写入交易记录,按交易号去重")
public Result<BillImportResultVO> confirm(HttpServletRequest request,
@RequestBody @Valid BillImportRequest body) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(billImportService.confirm(userId, body));
}
/**
* 将账单类型字符串转换为枚举
*
* @param billType 账单类型字符串
* @return 账单类型枚举
*/
private BillType resolveBillType(String billType) {
try {
return BillType.valueOf(billType);
} catch (IllegalArgumentException e) {
throw new BusinessException("不支持的账单类型:" + billType);
}
}
}
@@ -2,9 +2,13 @@ package com.example.finance.controller;
import com.example.finance.common.Result;
import com.example.finance.dto.StatisticsQuery;
import com.example.finance.dto.StatisticsRangeQuery;
import com.example.finance.enums.StatisticsDimension;
import com.example.finance.service.StatisticsService;
import com.example.finance.vo.CategoryStatisticsVO;
import com.example.finance.vo.MonthlyStatisticsVO;
import com.example.finance.vo.StatisticsGroupVO;
import com.example.finance.vo.StatisticsSummaryVO;
import com.example.finance.vo.TrendStatisticsVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -13,6 +17,7 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@@ -77,4 +82,36 @@ public class StatisticsController {
Long userId = (Long) request.getAttribute("userId");
return Result.success(statisticsService.getTrendStatistics(userId, query));
}
/**
* 获取指定时间范围的统计概览
*
* @param request HTTP请求,从中获取用户ID
* @param query 时间范围查询参数
* @return 统计概览
*/
@GetMapping("/summary")
@Operation(summary = "获取统计概览", description = "获取当前用户指定时间范围的收支汇总、日均、最大单笔与平均每笔")
public Result<StatisticsSummaryVO> summary(HttpServletRequest request,
@Parameter(description = "时间范围查询参数") StatisticsRangeQuery query) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(statisticsService.getSummary(userId, query));
}
/**
* 按维度获取分组统计数据
*
* @param request HTTP请求,从中获取用户ID
* @param dimension 统计维度(day/week/month/quarter/year/paymentMethod/category/type)
* @param query 时间范围查询参数
* @return 分组统计列表
*/
@GetMapping("/group")
@Operation(summary = "获取分组统计数据", description = "按指定维度对时间范围内的交易进行分组统计")
public Result<List<StatisticsGroupVO>> group(HttpServletRequest request,
@Parameter(description = "统计维度") @RequestParam("dimension") String dimension,
@Parameter(description = "时间范围查询参数") StatisticsRangeQuery query) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(statisticsService.getGroup(userId, query, StatisticsDimension.from(dimension)));
}
}
@@ -3,6 +3,7 @@ package com.example.finance.controller;
import com.alibaba.excel.EasyExcel;
import com.example.finance.common.PageResult;
import com.example.finance.common.Result;
import com.example.finance.dto.BatchCategoryRequest;
import com.example.finance.dto.TransactionQuery;
import com.example.finance.dto.TransactionRequest;
import com.example.finance.service.TransactionService;
@@ -130,6 +131,40 @@ public class TransactionController {
return Result.success();
}
/**
* 切换交易记录的备注隐藏标记
*
* @param request HTTP请求,从中获取用户ID
* @param id 交易记录ID
* @param body 请求体,包含 remarkHidden 字段
* @return 操作结果
*/
@PutMapping("/{id}/remark-hidden")
@Operation(summary = "切换备注隐藏标记", description = "切换交易记录的备注是否隐藏(用于表格中单元格按钮)")
public Result<Void> toggleRemarkHidden(HttpServletRequest request,
@Parameter(description = "交易记录ID") @PathVariable Long id,
@RequestBody Map<String, Boolean> body) {
Long userId = (Long) request.getAttribute("userId");
Boolean remarkHidden = body != null ? body.get("remarkHidden") : null;
transactionService.toggleRemarkHidden(userId, id, remarkHidden);
return Result.success();
}
/**
* 批量设置交易记录的分类
*
* @param request HTTP请求,从中获取用户ID
* @param body 批量设置请求参数
* @return 实际更新的记录数
*/
@PutMapping("/batch-category")
@Operation(summary = "批量设置分类", description = "将当前表格展示的多条交易记录按类型一键归入所选分类")
public Result<Integer> batchUpdateCategory(HttpServletRequest request,
@RequestBody @Valid BatchCategoryRequest body) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(transactionService.batchUpdateCategory(userId, body));
}
/**
* 导出交易记录为 Excel 文件
*
@@ -0,0 +1,25 @@
package com.example.finance.dto;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.List;
/**
* 批量设置分类请求 DTO,用于将当前表格展示的多条交易记录一键归入分类。
*
* @date 2026-09-17 10:24:18
*/
@Data
public class BatchCategoryRequest {
/** 待更新的交易记录ID列表 */
@NotEmpty(message = "请至少选择一条记录")
private List<Long> ids;
/** 支出默认分类ID,为空表示不更新支出记录 */
private Long expenseCategoryId;
/** 收入默认分类ID,为空表示不更新收入记录 */
private Long incomeCategoryId;
}
@@ -0,0 +1,58 @@
package com.example.finance.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 账单导入请求 DTO,用于「后导入」阶段接收前端回传的勾选行。
*
* @date 2026-09-17 09:50:04
*/
@Data
public class BillImportRequest {
/** 账单类型,如 ALIPAY */
@NotBlank(message = "账单类型不能为空")
private String billType;
/** 支出默认分类ID,适用于所有支出类型行 */
private Long expenseCategoryId;
/** 收入默认分类ID,适用于所有收入类型行 */
private Long incomeCategoryId;
/** 勾选待导入的账单行列表 */
private List<Item> items;
/**
* 单条待导入账单行
*/
@Data
public static class Item {
/** 外部账单交易号,用于去重 */
private String externalNo;
/** 交易时间 */
private LocalDateTime transactionTime;
/** 交易类型:INCOME-收入,EXPENSE-支出 */
private String type;
/** 交易金额 */
private BigDecimal amount;
/** 交易对方 */
private String counterparty;
/** 商品名称 */
private String product;
/** 是否计入统计 */
private Boolean includeInStats;
}
}
@@ -0,0 +1,42 @@
package com.example.finance.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
* 统计查询 DTO,用于按时间区间及可选过滤条件进行统计。
*
* @date 2026-09-17 10:50:32
*/
@Data
public class StatisticsRangeQuery {
/** 起始时间,ISO 格式(yyyy-MM-dd 或 yyyy-MM-ddTHH:mm:ss),可为空 */
private String startTime;
/** 结束时间,ISO 格式(yyyy-MM-dd 或 yyyy-MM-ddTHH:mm:ss),可为空 */
private String endTime;
/** 交易类型筛选(INCOME/EXPENSE),支持多选 */
private List<String> types;
/** 分类ID筛选,支持多选 */
private List<Long> categoryIds;
/** 是否仅筛选未分类记录;与 categoryIds 同时存在时取并集 */
private Boolean uncategorized;
/** 支付方式筛选(WECHAT/ALIPAY/CASH/BANK),支持多选 */
private List<String> paymentMethods;
/** 最小金额(含),可选 */
private BigDecimal minAmount;
/** 最大金额(含),可选 */
private BigDecimal maxAmount;
/** 备注关键字,模糊匹配 */
private String remark;
}
@@ -2,6 +2,7 @@ package com.example.finance.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
@@ -22,6 +23,9 @@ public class TransactionQuery {
/** 分类ID筛选,支持多选 */
private List<Long> categoryIds;
/** 是否仅筛选未分类记录;与 categoryIds 同时存在时取并集 */
private Boolean uncategorized;
/** 查询起始时间 */
private String startTime;
@@ -33,4 +37,13 @@ public class TransactionQuery {
/** 是否计入统计筛选:null-全部,true-仅计入,false-仅不计入 */
private Boolean includeInStats;
/** 最小金额(含),可选 */
private BigDecimal minAmount;
/** 最大金额(含),可选 */
private BigDecimal maxAmount;
/** 备注关键字,模糊匹配 */
private String remark;
}
@@ -37,6 +37,12 @@ public class Transaction {
/** 备注 */
private String remark;
/** 备注是否隐藏:true-隐藏(列表中显示占位),false-显示 */
private Boolean remarkHidden;
/** 外部账单交易号,用于账单导入去重(同一用户内唯一) */
private String externalNo;
/** 交易时间 */
private LocalDateTime transactionTime;
@@ -0,0 +1,30 @@
package com.example.finance.enums;
/**
* 账单类型枚举,标识支持的第三方账单来源。
*
* <p>当前仅支持支付宝,后续可扩展微信、银行等账单类型。</p>
*
* @date 2026-09-17 09:49:43
*/
public enum BillType {
/** 支付宝账单 */
ALIPAY("支付宝");
/** 账单类型的中文名称 */
private final String label;
BillType(String label) {
this.label = label;
}
/**
* 获取账单类型的中文名称
*
* @return 中文名称
*/
public String getLabel() {
return label;
}
}
@@ -0,0 +1,62 @@
package com.example.finance.enums;
import com.example.finance.common.BusinessException;
/**
* 统计维度枚举,定义分组统计时可选的分组方式。
*
* @date 2026-09-17 10:50:32
*/
public enum StatisticsDimension {
/** 按日 */
DAY(true),
/** 按周(ISO 周) */
WEEK(true),
/** 按月 */
MONTH(true),
/** 按季度 */
QUARTER(true),
/** 按年 */
YEAR(true),
/** 按支付方式 */
PAYMENT_METHOD(false),
/** 按分类 */
CATEGORY(false),
/** 按交易类型 */
TYPE(false);
/** 是否为时间维度 */
private final boolean time;
StatisticsDimension(boolean time) {
this.time = time;
}
/**
* 是否为时间维度
*
* @return true-时间维度,false-其它维度
*/
public boolean isTime() {
return time;
}
/**
* 将字符串解析为统计维度,兼容 camelCase 与 UPPER_SNAKE_CASE
*
* @param value 维度字符串,如 day、paymentMethod、PAYMENT_METHOD
* @return 统计维度
*/
public static StatisticsDimension from(String value) {
if (value == null || value.isBlank()) {
throw new BusinessException("统计维度不能为空");
}
String normalized = value.replaceAll("([a-z])([A-Z])", "$1_$2").toUpperCase();
try {
return valueOf(normalized);
} catch (IllegalArgumentException e) {
throw new BusinessException("不支持的统计维度:" + value);
}
}
}
@@ -0,0 +1,253 @@
package com.example.finance.importer;
import com.example.finance.enums.BillType;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVParser;
import org.apache.commons.csv.CSVRecord;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
/**
* 支付宝账单解析器。
*
* <p>解析支付宝「交易记录明细查询」导出的 CSV 文件。文件为 GBK 编码(兼容 UTF-8),
* 前几行为账号、日期范围等元信息,之后为表头与数据行,末尾为统计信息。</p>
*
* @date 2026-09-17 09:50:01
*/
@Component
public class AlipayBillParser implements BillParser {
/** 表头列名 */
private static final String COL_TRADE_NO = "交易号";
private static final String COL_CREATE_TIME = "交易创建时间";
private static final String COL_PAY_TIME = "付款时间";
private static final String COL_COUNTERPARTY = "交易对方";
private static final String COL_PRODUCT = "商品名称";
private static final String COL_AMOUNT = "金额(元)";
private static final String COL_DIRECTION = "收/支";
private static final String COL_STATUS = "交易状态";
private static final String COL_FUND_STATUS = "资金状态";
/** 支付宝账单中的时间格式 */
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/** CSV 解析格式:逗号分隔并去除字段首尾空白 */
private static final CSVFormat CSV_FORMAT = CSVFormat.DEFAULT.builder().setTrim(true).build();
@Override
public BillType getBillType() {
return BillType.ALIPAY;
}
/**
* {@inheritDoc}
*
* 定位表头后逐行解析数据,遇到分隔线或「共 N 笔记录」统计行即结束。
*/
@Override
public List<ParsedBillRow> parse(byte[] content) throws IOException {
String text = new String(content, detectCharset(content));
String[] lines = text.split("\r?\n", -1);
// 定位表头行:以「交易号」开头
int headerIndex = -1;
for (int i = 0; i < lines.length; i++) {
if (lines[i].startsWith(COL_TRADE_NO)) {
headerIndex = i;
break;
}
}
if (headerIndex < 0) {
throw new IOException("未找到支付宝账单表头,请确认文件是否为支付宝导出的交易记录明细");
}
List<String> header = parseLine(lines[headerIndex]);
List<ParsedBillRow> rows = new ArrayList<>();
int rowNo = 0;
for (int i = headerIndex + 1; i < lines.length; i++) {
String trimmed = lines[i].trim();
// 分隔线、统计信息或空行表示数据结束
if (trimmed.isEmpty() || trimmed.startsWith("---") || trimmed.startsWith("共")) {
break;
}
List<String> values = parseLine(lines[i]);
if (values.size() < 2) {
continue;
}
rowNo++;
rows.add(toRow(rowNo, header, values));
}
return rows;
}
/**
* 将一行 CSV 文本解析为字段列表
*
* @param line CSV 行文本
* @return 字段列表
* @throws IOException 解析失败
*/
private List<String> parseLine(String line) throws IOException {
try (CSVParser parser = CSVParser.parse(line, CSV_FORMAT)) {
for (CSVRecord record : parser) {
List<String> values = new ArrayList<>(record.size());
for (String value : record) {
values.add(value);
}
return values;
}
}
return List.of();
}
/**
* 将单行数据映射为账单行对象,并推断类型、是否计入统计及默认勾选状态
*
* @param rowNo 行号
* @param header 表头字段列表
* @param values 数据字段列表
* @return 解析后的账单行
*/
private ParsedBillRow toRow(int rowNo, List<String> header, List<String> values) {
ParsedBillRow row = new ParsedBillRow();
row.setRowNo(rowNo);
row.setExternalNo(get(header, values, COL_TRADE_NO));
row.setCounterparty(get(header, values, COL_COUNTERPARTY));
row.setProduct(get(header, values, COL_PRODUCT));
row.setTradeStatus(get(header, values, COL_STATUS));
String direction = get(header, values, COL_DIRECTION);
String fundStatus = get(header, values, COL_FUND_STATUS);
row.setDirection(direction);
// 交易时间优先取付款时间,为空时回退交易创建时间
String payTime = get(header, values, COL_PAY_TIME);
String createTime = get(header, values, COL_CREATE_TIME);
String timeStr = StringUtils.hasText(payTime) ? payTime : createTime;
if (StringUtils.hasText(timeStr)) {
try {
row.setTransactionTime(LocalDateTime.parse(timeStr, TIME_FORMATTER));
} catch (DateTimeParseException e) {
row.setError("交易时间格式无法解析:" + timeStr);
}
}
// 金额去除千分位逗号后解析
String amountStr = get(header, values, COL_AMOUNT);
if (StringUtils.hasText(amountStr)) {
try {
row.setAmount(new BigDecimal(amountStr.replace(",", "")));
} catch (NumberFormatException e) {
row.setError("金额格式无法解析:" + amountStr);
}
}
// 根据收/支与交易状态推断交易类型
boolean noStats = "不计收支".equals(direction);
String type = resolveType(direction, fundStatus, row.getTradeStatus());
row.setType(type);
if (type == null && row.getError() == null) {
row.setError("无法识别收/支类型:" + direction);
}
// 不计收支(退款、交易关闭等)默认不计入统计
row.setIncludeInStats(!noStats);
// 校验必要字段,决定是否可导入
boolean importable = row.getError() == null
&& StringUtils.hasText(row.getExternalNo())
&& row.getTransactionTime() != null
&& row.getAmount() != null
&& row.getAmount().compareTo(BigDecimal.ZERO) > 0
&& type != null;
row.setImportable(importable);
// 不计收支行默认不勾选,用户可手动勾选
row.setSelected(importable && !noStats);
return row;
}
/**
* 推断交易类型
*
* @param direction 收/支标识
* @param fundStatus 资金状态
* @param tradeStatus 交易状态
* @return INCOME、EXPENSE,无法识别时返回 null
*/
private String resolveType(String direction, String fundStatus, String tradeStatus) {
if ("支出".equals(direction)) {
return "EXPENSE";
}
if ("收入".equals(direction)) {
return "INCOME";
}
if ("不计收支".equals(direction)) {
// 退款成功或资金已收入视为收入,其余(如交易关闭)按支出处理
if (StringUtils.hasText(tradeStatus) && tradeStatus.contains("退款")) {
return "INCOME";
}
if ("已收入".equals(fundStatus)) {
return "INCOME";
}
return "EXPENSE";
}
return null;
}
/**
* 按表头列名取值
*
* @param header 表头字段列表
* @param values 数据字段列表
* @param column 列名
* @return 字段值,列不存在时返回 null
*/
private String get(List<String> header, List<String> values, String column) {
int index = header.indexOf(column);
if (index < 0 || index >= values.size()) {
return null;
}
String value = values.get(index);
return value == null ? null : value.trim();
}
/**
* 检测账单文件的字符编码:优先 UTF-8(含 BOM),否则回退 GBK
*
* @param content 文件字节内容
* @return 字符集
*/
private Charset detectCharset(byte[] content) {
// UTF-8 BOM
if (content.length >= 3
&& (content[0] & 0xFF) == 0xEF
&& (content[1] & 0xFF) == 0xBB
&& (content[2] & 0xFF) == 0xBF) {
return StandardCharsets.UTF_8;
}
// 严格尝试 UTF-8 解码,失败则判定为 GBK
try {
StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(content));
return StandardCharsets.UTF_8;
} catch (CharacterCodingException e) {
return Charset.forName("GBK");
}
}
}
@@ -0,0 +1,33 @@
package com.example.finance.importer;
import com.example.finance.enums.BillType;
import java.io.IOException;
import java.util.List;
/**
* 账单解析器接口,定义不同账单来源的解析行为。
*
* <p>各实现类通过 {@link #getBillType()} 声明自己支持的账单类型,
* 由服务层按账单类型分发,便于后续扩展新的账单来源。</p>
*
* @date 2026-09-17 09:49:45
*/
public interface BillParser {
/**
* 获取该解析器支持的账单类型
*
* @return 账单类型
*/
BillType getBillType();
/**
* 解析账单文件内容
*
* @param content 账单文件的原始字节内容
* @return 解析后的账单行列表
* @throws IOException 读取或解析文件失败
*/
List<ParsedBillRow> parse(byte[] content) throws IOException;
}
@@ -0,0 +1,59 @@
package com.example.finance.importer;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 账单解析结果行,作为「先解析、后导入」流程中解析阶段的中间对象。
*
* <p>解析阶段不落库,仅返回给前端预览;确认导入时前端将勾选行回传,后端据此落库。</p>
*
* @date 2026-09-17 09:49:44
*/
@Data
public class ParsedBillRow {
/** 账单内行号,从 1 开始 */
private int rowNo;
/** 外部账单交易号,用于导入去重 */
private String externalNo;
/** 交易时间 */
private LocalDateTime transactionTime;
/** 交易类型:INCOME-收入,EXPENSE-支出 */
private String type;
/** 交易金额 */
private BigDecimal amount;
/** 交易对方 */
private String counterparty;
/** 商品名称 */
private String product;
/** 交易状态,如「交易成功」「交易关闭」「退款成功」 */
private String tradeStatus;
/** 原始收/支标识,如「支出」「收入」「不计收支」 */
private String direction;
/** 是否计入统计 */
private Boolean includeInStats;
/** 是否可导入(解析失败或缺少必要字段时为 false) */
private boolean importable;
/** 是否默认勾选 */
private boolean selected;
/** 是否与系统中已有记录重复(同一用户下交易号已存在) */
private boolean duplicate;
/** 解析失败原因,正常时为 null */
private String error;
}
@@ -0,0 +1,36 @@
package com.example.finance.service;
import com.example.finance.dto.BillImportRequest;
import com.example.finance.enums.BillType;
import com.example.finance.vo.BillImportResultVO;
import com.example.finance.vo.BillParseResultVO;
import java.io.IOException;
/**
* 账单导入服务,负责「先解析、后导入」两阶段的业务逻辑。
*
* @date 2026-09-17 09:50:10
*/
public interface BillImportService {
/**
* 解析账单文件,返回预览数据(不落库),并标记与系统已有记录重复的行。
*
* @param userId 当前用户ID
* @param billType 账单类型
* @param content 账单文件字节内容
* @return 解析结果
* @throws IOException 文件解析失败
*/
BillParseResultVO parse(Long userId, BillType billType, byte[] content) throws IOException;
/**
* 导入用户勾选的账单行,按外部交易号去重并写入交易记录。
*
* @param userId 当前用户ID
* @param request 导入请求
* @return 导入结果统计
*/
BillImportResultVO confirm(Long userId, BillImportRequest request);
}
@@ -1,8 +1,12 @@
package com.example.finance.service;
import com.example.finance.dto.StatisticsQuery;
import com.example.finance.dto.StatisticsRangeQuery;
import com.example.finance.enums.StatisticsDimension;
import com.example.finance.vo.CategoryStatisticsVO;
import com.example.finance.vo.MonthlyStatisticsVO;
import com.example.finance.vo.StatisticsGroupVO;
import com.example.finance.vo.StatisticsSummaryVO;
import com.example.finance.vo.TrendStatisticsVO;
import java.util.List;
@@ -38,4 +42,23 @@ public interface StatisticsService {
* @return 按日排列的趋势统计列表
*/
List<TrendStatisticsVO> getTrendStatistics(Long userId, StatisticsQuery query);
/**
* 获取指定时间范围内的统计概览(收支汇总、日均、最大单笔、平均每笔)
*
* @param userId 用户ID
* @param query 时间范围查询条件
* @return 统计概览
*/
StatisticsSummaryVO getSummary(Long userId, StatisticsRangeQuery query);
/**
* 按指定维度对时间范围内的交易进行分组统计
*
* @param userId 用户ID
* @param query 时间范围查询条件
* @param dimension 统计维度
* @return 分组统计列表
*/
List<StatisticsGroupVO> getGroup(Long userId, StatisticsRangeQuery query, StatisticsDimension dimension);
}
@@ -1,6 +1,7 @@
package com.example.finance.service;
import com.example.finance.common.PageResult;
import com.example.finance.dto.BatchCategoryRequest;
import com.example.finance.dto.TransactionQuery;
import com.example.finance.dto.TransactionRequest;
import com.example.finance.vo.TransactionExportVO;
@@ -64,4 +65,22 @@ public interface TransactionService {
* @param includeInStats 新的标记值
*/
void toggleStats(Long userId, Long id, Boolean includeInStats);
/**
* 切换单条交易记录的备注隐藏标记
*
* @param userId 用户ID
* @param id 交易记录ID
* @param remarkHidden 新的标记值
*/
void toggleRemarkHidden(Long userId, Long id, Boolean remarkHidden);
/**
* 批量设置交易记录的分类(用于对当前表格展示的数据一键归类)
*
* @param userId 用户ID
* @param request 批量设置请求(记录ID列表、支出/收入默认分类)
* @return 实际更新的记录数
*/
int batchUpdateCategory(Long userId, BatchCategoryRequest request);
}
@@ -0,0 +1,256 @@
package com.example.finance.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.finance.common.BusinessException;
import com.example.finance.dto.BillImportRequest;
import com.example.finance.entity.Category;
import com.example.finance.entity.Transaction;
import com.example.finance.enums.BillType;
import com.example.finance.importer.BillParser;
import com.example.finance.importer.ParsedBillRow;
import com.example.finance.mapper.CategoryMapper;
import com.example.finance.mapper.TransactionMapper;
import com.example.finance.service.BillImportService;
import com.example.finance.vo.BillImportResultVO;
import com.example.finance.vo.BillParseResultVO;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 账单导入服务实现类。
*
* <p>解析阶段按账单类型分发到对应的 {@link BillParser},并标记重复行;
* 导入阶段校验分类归属、按外部交易号去重后逐条写入交易记录。</p>
*
* @date 2026-09-17 09:50:17
*/
@Service
public class BillImportServiceImpl implements BillImportService {
private final Map<BillType, BillParser> parserMap = new EnumMap<>(BillType.class);
private final TransactionMapper transactionMapper;
private final CategoryMapper categoryMapper;
public BillImportServiceImpl(List<BillParser> parsers,
TransactionMapper transactionMapper,
CategoryMapper categoryMapper) {
// 按账单类型注册解析器,便于扩展新账单来源
for (BillParser parser : parsers) {
parserMap.put(parser.getBillType(), parser);
}
this.transactionMapper = transactionMapper;
this.categoryMapper = categoryMapper;
}
/**
* {@inheritDoc}
*
* 解析后批量查询已存在的交易号,标记重复行并取消其默认勾选。
*/
@Override
public BillParseResultVO parse(Long userId, BillType billType, byte[] content) throws IOException {
BillParser parser = parserMap.get(billType);
if (parser == null) {
throw new BusinessException("暂不支持的账单类型:" + billType);
}
List<ParsedBillRow> rows = parser.parse(content);
// 批量查询已存在的交易号,用于标记重复
List<String> externalNos = rows.stream()
.map(ParsedBillRow::getExternalNo)
.filter(StringUtils::hasText)
.distinct()
.collect(Collectors.toList());
Set<String> existing = findExistingExternalNos(userId, externalNos);
for (ParsedBillRow row : rows) {
if (StringUtils.hasText(row.getExternalNo()) && existing.contains(row.getExternalNo())) {
row.setDuplicate(true);
row.setSelected(false);
}
}
BillParseResultVO vo = new BillParseResultVO();
vo.setBillType(billType.name());
vo.setTotal(rows.size());
vo.setRows(rows);
return vo;
}
/**
* {@inheritDoc}
*
* 逐条校验并插入,单条失败不影响其余记录,最终返回成功/跳过/失败统计。
*/
@Override
@Transactional
public BillImportResultVO confirm(Long userId, BillImportRequest request) {
List<BillImportRequest.Item> items = request.getItems();
if (items == null || items.isEmpty()) {
throw new BusinessException("请至少勾选一条记录");
}
// 分类为可选:仅在用户选择了默认分类时才校验其归属与类型
Category expenseCategory = request.getExpenseCategoryId() == null ? null
: requireCategory(userId, request.getExpenseCategoryId(), "EXPENSE");
Category incomeCategory = request.getIncomeCategoryId() == null ? null
: requireCategory(userId, request.getIncomeCategoryId(), "INCOME");
// 已存在交易号 + 本批次已插入交易号,共同用于去重
Set<String> seen = findExistingExternalNos(userId, items.stream()
.map(BillImportRequest.Item::getExternalNo)
.filter(StringUtils::hasText)
.distinct()
.collect(Collectors.toList()));
int imported = 0;
int skipped = 0;
int failed = 0;
List<String> messages = new ArrayList<>();
for (BillImportRequest.Item item : items) {
String label = StringUtils.hasText(item.getExternalNo()) ? item.getExternalNo() : "未知交易号";
try {
// 基础字段校验
if (!"INCOME".equals(item.getType()) && !"EXPENSE".equals(item.getType())) {
failed++;
messages.add(label + ":交易类型无效");
continue;
}
if (item.getTransactionTime() == null) {
failed++;
messages.add(label + ":交易时间为空");
continue;
}
if (item.getAmount() == null || item.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
failed++;
messages.add(label + ":金额无效");
continue;
}
// 去重:同一用户下交易号唯一
if (StringUtils.hasText(item.getExternalNo()) && seen.contains(item.getExternalNo())) {
skipped++;
messages.add(label + ":已存在,跳过");
continue;
}
Category category = "INCOME".equals(item.getType()) ? incomeCategory : expenseCategory;
Transaction transaction = new Transaction();
transaction.setUserId(userId);
// 未选择默认分类时置空,表示未分类
transaction.setCategoryId(category != null ? category.getId() : null);
transaction.setType(item.getType());
transaction.setAmount(item.getAmount());
transaction.setPaymentMethod("ALIPAY");
transaction.setRemark(truncate(buildRemark(item.getCounterparty(), item.getProduct()), 255));
transaction.setExternalNo(item.getExternalNo());
transaction.setTransactionTime(item.getTransactionTime());
transaction.setIncludeInStats(item.getIncludeInStats() == null ? Boolean.TRUE : item.getIncludeInStats());
transactionMapper.insert(transaction);
if (StringUtils.hasText(item.getExternalNo())) {
seen.add(item.getExternalNo());
}
imported++;
} catch (RuntimeException e) {
failed++;
messages.add(label + ":导入失败," + e.getMessage());
}
}
BillImportResultVO result = new BillImportResultVO();
result.setTotal(items.size());
result.setImported(imported);
result.setSkipped(skipped);
result.setFailed(failed);
result.setMessages(messages);
return result;
}
/**
* 查询指定交易号中已存在的记录
*
* @param userId 用户ID
* @param externalNos 交易号列表
* @return 已存在的交易号集合
*/
private Set<String> findExistingExternalNos(Long userId, List<String> externalNos) {
Set<String> existing = new HashSet<>();
if (externalNos.isEmpty()) {
return existing;
}
List<Transaction> list = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.select(Transaction::getExternalNo)
.eq(Transaction::getUserId, userId)
.in(Transaction::getExternalNo, externalNos));
for (Transaction t : list) {
if (t.getExternalNo() != null) {
existing.add(t.getExternalNo());
}
}
return existing;
}
/**
* 校验分类是否存在、归属当前用户且类型匹配
*
* @param userId 用户ID
* @param categoryId 分类ID
* @param type 期望的分类类型
* @return 分类实体
*/
private Category requireCategory(Long userId, Long categoryId, String type) {
if (categoryId == null) {
throw new BusinessException("请选择" + ("INCOME".equals(type) ? "收入" : "支出") + "默认分类");
}
Category category = categoryMapper.selectById(categoryId);
if (category == null || !category.getUserId().equals(userId)) {
throw new BusinessException("分类不存在");
}
if (!type.equals(category.getType())) {
throw new BusinessException("默认分类与交易类型不匹配");
}
return category;
}
/**
* 根据交易对方与商品名称构造备注
*
* @param counterparty 交易对方
* @param product 商品名称
* @return 备注文本
*/
private String buildRemark(String counterparty, String product) {
String c = counterparty == null ? "" : counterparty.trim();
String p = product == null ? "" : product.trim();
if (!c.isEmpty() && !p.isEmpty() && !c.equals(p)) {
return c + " · " + p;
}
return !c.isEmpty() ? c : p;
}
/**
* 截断字符串以适配数据库字段长度
*
* @param value 原字符串
* @param maxLength 最大长度
* @return 截断后的字符串
*/
private String truncate(String value, int maxLength) {
if (value == null || value.length() <= maxLength) {
return value;
}
return value.substring(0, maxLength);
}
}
@@ -1,16 +1,22 @@
package com.example.finance.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.example.finance.common.BusinessException;
import com.example.finance.dto.StatisticsQuery;
import com.example.finance.dto.StatisticsRangeQuery;
import com.example.finance.entity.Category;
import com.example.finance.entity.Transaction;
import com.example.finance.enums.StatisticsDimension;
import com.example.finance.mapper.CategoryMapper;
import com.example.finance.mapper.TransactionMapper;
import com.example.finance.service.StatisticsService;
import com.example.finance.vo.CategoryStatisticsVO;
import com.example.finance.vo.MonthlyStatisticsVO;
import com.example.finance.vo.StatisticsGroupVO;
import com.example.finance.vo.StatisticsSummaryVO;
import com.example.finance.vo.TrendStatisticsVO;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
@@ -18,14 +24,19 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* 统计服务实现类,提供月度收支汇总、分类占比分析和每日收支趋势统计等业务逻辑。
* 统计服务实现类,提供月度收支汇总、分类占比分析、趋势统计、概览指标及通用分组统计。
*
* <p>所有统计均基于 SQL 聚合(MyBatis-Plus {@code selectMaps})完成,避免将整段区间的
* 交易记录加载到内存;仅统计计入统计({@code include_in_stats = 1 或 NULL})的记录。</p>
*/
@Service
public class StatisticsServiceImpl implements StatisticsService {
@@ -41,149 +52,427 @@ public class StatisticsServiceImpl implements StatisticsService {
/**
* {@inheritDoc}
*
* 遍历指定月份的所有交易记录,分别累加收入和支出,计算结余和交易笔数。
* 复用概览统计,返回该月的收入、支出、结余与笔数。
*/
@Override
public MonthlyStatisticsVO getMonthlyStatistics(Long userId, StatisticsQuery query) {
List<Transaction> transactions = getMonthTransactions(userId, query);
StatisticsSummaryVO summary = getSummary(userId, monthToRange(query.getYear(), query.getMonth()));
MonthlyStatisticsVO vo = new MonthlyStatisticsVO();
BigDecimal totalIncome = BigDecimal.ZERO;
BigDecimal totalExpense = BigDecimal.ZERO;
// 按交易类型分别累加收入和支出
for (Transaction t : transactions) {
if ("INCOME".equals(t.getType())) {
totalIncome = totalIncome.add(t.getAmount());
} else {
totalExpense = totalExpense.add(t.getAmount());
}
}
vo.setTotalIncome(totalIncome);
vo.setTotalExpense(totalExpense);
vo.setBalance(totalIncome.subtract(totalExpense));
vo.setTransactionCount(transactions.size());
vo.setTotalIncome(summary.getTotalIncome());
vo.setTotalExpense(summary.getTotalExpense());
vo.setBalance(summary.getBalance());
vo.setTransactionCount(summary.getTransactionCount());
return vo;
}
/**
* {@inheritDoc}
*
* 按分类ID分组统计交易金额、笔数,并计算各分类占总金额的百分比,结果按金额降序排列。
* 按分类维度聚合该月数据,并填充分类名称、图标与类型。
*/
@Override
public List<CategoryStatisticsVO> getCategoryStatistics(Long userId, StatisticsQuery query) {
List<Transaction> transactions = getMonthTransactions(userId, query);
// 按分类ID分组
Map<Long, List<Transaction>> grouped = transactions.stream()
.collect(Collectors.groupingBy(Transaction::getCategoryId));
// 批量查询分类信息
List<Long> categoryIds = new ArrayList<>(grouped.keySet());
Map<Long, Category> categoryMap = Map.of();
if (!categoryIds.isEmpty()) {
List<Category> categories = categoryMapper.selectBatchIds(categoryIds);
categoryMap = categories.stream().collect(Collectors.toMap(Category::getId, c -> c));
}
// 计算总金额,用于后续计算百分比
BigDecimal totalAmount = transactions.stream()
.map(Transaction::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// 构建每个分类的统计结果
Map<Long, Category> finalCategoryMap = categoryMap;
return grouped.entrySet().stream()
.map(entry -> {
List<Transaction> list = entry.getValue();
Category category = finalCategoryMap.get(entry.getKey());
BigDecimal amount = list.stream()
.map(Transaction::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
CategoryStatisticsVO vo = new CategoryStatisticsVO();
vo.setCategoryId(entry.getKey());
vo.setCategoryName(category != null ? category.getName() : "未知");
vo.setCategoryIcon(category != null ? category.getIcon() : "");
vo.setType(category != null ? category.getType() : "");
vo.setAmount(amount);
vo.setCount(list.size());
// 计算占比百分比,保留两位小数,四舍五入
if (totalAmount.compareTo(BigDecimal.ZERO) > 0) {
vo.setPercentage(amount.multiply(BigDecimal.valueOf(100))
.divide(totalAmount, 2, RoundingMode.HALF_UP));
} else {
vo.setPercentage(BigDecimal.ZERO);
}
return vo;
})
// 按金额降序排列
.sorted((a, b) -> b.getAmount().compareTo(a.getAmount()))
.collect(Collectors.toList());
List<StatisticsGroupVO> groups = getGroup(userId, monthToRange(query.getYear(), query.getMonth()),
StatisticsDimension.CATEGORY);
return groups.stream().map(g -> {
CategoryStatisticsVO vo = new CategoryStatisticsVO();
vo.setCategoryId(parseLong(g.getKey()));
vo.setCategoryName(g.getName());
vo.setCategoryIcon(g.getIcon());
vo.setType(g.getType());
vo.setAmount(g.getTotal());
vo.setCount(g.getCount());
vo.setPercentage(g.getPercentage());
return vo;
}).collect(Collectors.toList());
}
/**
* {@inheritDoc}
*
* 统计指定月份每天的收入和支出,返回按日期排列的趋势数据列表。
* 按日聚合该月数据,并补全当月无交易的日期(金额为 0)。
*/
@Override
public List<TrendStatisticsVO> getTrendStatistics(Long userId, StatisticsQuery query) {
YearMonth yearMonth = YearMonth.of(query.getYear(), query.getMonth());
int daysInMonth = yearMonth.lengthOfMonth();
List<StatisticsGroupVO> groups = getGroup(userId, monthToRange(query.getYear(), query.getMonth()),
StatisticsDimension.DAY);
Map<String, StatisticsGroupVO> byDate = groups.stream()
.filter(g -> g.getKey() != null)
.collect(Collectors.toMap(StatisticsGroupVO::getKey, g -> g, (a, b) -> a));
// 初始化每日收入和支出为零
Map<Integer, BigDecimal> incomeMap = new HashMap<>();
Map<Integer, BigDecimal> expenseMap = new HashMap<>();
for (int i = 1; i <= daysInMonth; i++) {
incomeMap.put(i, BigDecimal.ZERO);
expenseMap.put(i, BigDecimal.ZERO);
}
// 累加每日交易金额
List<Transaction> transactions = getMonthTransactions(userId, query);
for (Transaction t : transactions) {
int day = t.getTransactionTime().getDayOfMonth();
if ("INCOME".equals(t.getType())) {
incomeMap.merge(day, t.getAmount(), BigDecimal::add);
} else {
expenseMap.merge(day, t.getAmount(), BigDecimal::add);
}
}
// 组装每日趋势数据
List<TrendStatisticsVO> result = new ArrayList<>();
for (int i = 1; i <= daysInMonth; i++) {
int days = yearMonth.lengthOfMonth();
for (int day = 1; day <= days; day++) {
String label = String.format("%d-%02d-%02d", query.getYear(), query.getMonth(), day);
StatisticsGroupVO group = byDate.get(label);
TrendStatisticsVO vo = new TrendStatisticsVO();
vo.setDate(String.format("%d-%02d-%02d", query.getYear(), query.getMonth(), i));
vo.setIncome(incomeMap.get(i));
vo.setExpense(expenseMap.get(i));
vo.setDate(label);
vo.setIncome(group != null ? group.getIncome() : BigDecimal.ZERO);
vo.setExpense(group != null ? group.getExpense() : BigDecimal.ZERO);
result.add(vo);
}
return result;
}
/**
* 查询指定月份内用户的所有交易记录(仅包含计入统计的记录)
* {@inheritDoc}
*
* 一条聚合查询取收支总额与笔数,另查最大单笔支出,并计算日均与平均每笔。
*/
@Override
public StatisticsSummaryVO getSummary(Long userId, StatisticsRangeQuery query) {
QueryWrapper<Transaction> wrapper = baseWrapper(userId, query);
wrapper.select(
"COALESCE(SUM(CASE WHEN type = 'INCOME' THEN amount ELSE 0 END), 0) AS totalIncome",
"COALESCE(SUM(CASE WHEN type = 'EXPENSE' THEN amount ELSE 0 END), 0) AS totalExpense",
"COUNT(*) AS totalCount",
"COALESCE(SUM(CASE WHEN type = 'INCOME' THEN 1 ELSE 0 END), 0) AS incomeCount",
"COALESCE(SUM(CASE WHEN type = 'EXPENSE' THEN 1 ELSE 0 END), 0) AS expenseCount");
List<Map<String, Object>> rows = transactionMapper.selectMaps(wrapper);
Map<String, Object> row = rows.isEmpty() ? Map.of() : rows.get(0);
BigDecimal totalIncome = toBigDecimal(row.get("totalIncome"));
BigDecimal totalExpense = toBigDecimal(row.get("totalExpense"));
int expenseCount = toInt(row.get("expenseCount"));
// 最大单笔支出
QueryWrapper<Transaction> maxWrapper = baseWrapper(userId, query);
maxWrapper.eq("type", "EXPENSE").orderByDesc("amount").last("LIMIT 1");
Transaction maxExpense = transactionMapper.selectOne(maxWrapper);
int dayCount = resolveDayCount(userId, query);
StatisticsSummaryVO vo = new StatisticsSummaryVO();
vo.setTotalIncome(totalIncome);
vo.setTotalExpense(totalExpense);
vo.setBalance(totalIncome.subtract(totalExpense));
vo.setTransactionCount(toInt(row.get("totalCount")));
vo.setDayCount(dayCount);
vo.setAvgDailyIncome(divide(totalIncome, dayCount));
vo.setAvgDailyExpense(divide(totalExpense, dayCount));
vo.setAvgExpenseAmount(divide(totalExpense, expenseCount));
if (maxExpense != null) {
vo.setMaxExpenseAmount(maxExpense.getAmount());
vo.setMaxExpenseTime(maxExpense.getTransactionTime());
vo.setMaxExpenseRemark(maxExpense.getRemark());
} else {
vo.setMaxExpenseAmount(BigDecimal.ZERO);
}
return vo;
}
/**
* {@inheritDoc}
*
* 按维度拼接分组表达式做 SQL 聚合,再在内存中解析名称、图标并计算占比与排序。
*/
@Override
public List<StatisticsGroupVO> getGroup(Long userId, StatisticsRangeQuery query, StatisticsDimension dimension) {
String expr = groupExpression(dimension);
QueryWrapper<Transaction> wrapper = baseWrapper(userId, query);
wrapper.select(expr + " AS grp",
"COALESCE(SUM(CASE WHEN type = 'INCOME' THEN amount ELSE 0 END), 0) AS income",
"COALESCE(SUM(CASE WHEN type = 'EXPENSE' THEN amount ELSE 0 END), 0) AS expense",
"COUNT(*) AS cnt")
.groupBy(expr);
if (dimension.isTime()) {
wrapper.orderByAsc(expr);
}
List<Map<String, Object>> rows = transactionMapper.selectMaps(wrapper);
// 分类维度需批量查询分类信息
Map<Long, Category> categoryMap = Map.of();
if (dimension == StatisticsDimension.CATEGORY) {
List<Long> categoryIds = rows.stream()
.map(r -> parseLong(stringValue(r.get("grp"))))
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
if (!categoryIds.isEmpty()) {
categoryMap = categoryMapper.selectBatchIds(categoryIds).stream()
.collect(Collectors.toMap(Category::getId, c -> c));
}
}
List<StatisticsGroupVO> result = new ArrayList<>();
BigDecimal grandTotal = BigDecimal.ZERO;
for (Map<String, Object> r : rows) {
String key = stringValue(r.get("grp"));
BigDecimal income = toBigDecimal(r.get("income"));
BigDecimal expense = toBigDecimal(r.get("expense"));
BigDecimal total = income.add(expense);
StatisticsGroupVO vo = new StatisticsGroupVO();
vo.setKey(key);
vo.setIncome(income);
vo.setExpense(expense);
vo.setTotal(total);
vo.setCount(toInt(r.get("cnt")));
fillGroupMeta(vo, dimension, key, categoryMap, income, expense);
result.add(vo);
grandTotal = grandTotal.add(total);
}
// 非时间维度按金额降序并计算占比
if (!dimension.isTime()) {
for (StatisticsGroupVO vo : result) {
vo.setPercentage(grandTotal.compareTo(BigDecimal.ZERO) > 0
? vo.getTotal().multiply(BigDecimal.valueOf(100)).divide(grandTotal, 2, RoundingMode.HALF_UP)
: BigDecimal.ZERO);
}
result.sort((a, b) -> b.getTotal().compareTo(a.getTotal()));
}
return result;
}
/**
* 构造带用户、时间区间、过滤条件与计入统计条件的查询包装器
*
* @param userId 用户ID
* @param query 统计查询条件(年份、月份)
* @return 该月份的交易记录列表
* @param query 统计查询条件(时间范围及可选过滤条件)
* @return 查询包装器
*/
private List<Transaction> getMonthTransactions(Long userId, StatisticsQuery query) {
YearMonth yearMonth = YearMonth.of(query.getYear(), query.getMonth());
// 计算月份的起止时间
LocalDateTime startTime = yearMonth.atDay(1).atStartOfDay();
LocalDateTime endTime = yearMonth.atEndOfMonth().atTime(LocalTime.MAX);
private QueryWrapper<Transaction> baseWrapper(Long userId, StatisticsRangeQuery query) {
QueryWrapper<Transaction> wrapper = new QueryWrapper<>();
wrapper.eq("user_id", userId);
return transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.eq(Transaction::getUserId, userId)
.ge(Transaction::getTransactionTime, startTime)
.le(Transaction::getTransactionTime, endTime)
// 仅统计计入统计的记录(排除借款、账户间转账等)
.and(w -> w.eq(Transaction::getIncludeInStats, true)
.or().isNull(Transaction::getIncludeInStats)));
if (query != null) {
LocalDateTime start = parseTime(query.getStartTime(), false);
LocalDateTime end = parseTime(query.getEndTime(), true);
if (start != null) {
wrapper.ge("transaction_time", start);
}
if (end != null) {
wrapper.le("transaction_time", end);
}
// 交易类型筛选(多选)
if (query.getTypes() != null && !query.getTypes().isEmpty()) {
wrapper.in("type", query.getTypes());
}
// 分类筛选(多选);同时勾选「未分类」时与其取并集
boolean hasCategoryIds = query.getCategoryIds() != null && !query.getCategoryIds().isEmpty();
boolean uncategorized = Boolean.TRUE.equals(query.getUncategorized());
if (hasCategoryIds && uncategorized) {
wrapper.and(w -> w.in("category_id", query.getCategoryIds()).or().isNull("category_id"));
} else if (hasCategoryIds) {
wrapper.in("category_id", query.getCategoryIds());
} else if (uncategorized) {
wrapper.isNull("category_id");
}
// 支付方式筛选(多选)
if (query.getPaymentMethods() != null && !query.getPaymentMethods().isEmpty()) {
wrapper.in("payment_method", query.getPaymentMethods());
}
// 金额区间筛选
if (query.getMinAmount() != null) {
wrapper.ge("amount", query.getMinAmount());
}
if (query.getMaxAmount() != null) {
wrapper.le("amount", query.getMaxAmount());
}
// 备注关键字模糊匹配
if (StringUtils.hasText(query.getRemark())) {
wrapper.like("remark", query.getRemark());
}
}
// 仅统计计入统计的记录(排除借款、账户间转账等)
wrapper.and(w -> w.eq("include_in_stats", true).or().isNull("include_in_stats"));
return wrapper;
}
/**
* 计算统计区间天数:优先按起止日期计算,否则统计有交易的天数
*
* @param userId 用户ID
* @param query 时间范围查询条件
* @return 天数
*/
private int resolveDayCount(Long userId, StatisticsRangeQuery query) {
LocalDateTime start = parseTime(query == null ? null : query.getStartTime(), false);
LocalDateTime end = parseTime(query == null ? null : query.getEndTime(), true);
if (start != null && end != null) {
long days = ChronoUnit.DAYS.between(start.toLocalDate(), end.toLocalDate()) + 1;
return (int) Math.max(days, 1);
}
QueryWrapper<Transaction> wrapper = baseWrapper(userId, query);
wrapper.select("COUNT(DISTINCT DATE(transaction_time)) AS dayCount");
List<Map<String, Object>> rows = transactionMapper.selectMaps(wrapper);
return rows.isEmpty() ? 0 : toInt(rows.get(0).get("dayCount"));
}
/**
* 填充分组结果的显示名称、图标与类型
*
* @param vo 分组结果
* @param dimension 统计维度
* @param key 分组键
* @param categoryMap 分类映射
* @param income 收入金额
* @param expense 支出金额
*/
private void fillGroupMeta(StatisticsGroupVO vo, StatisticsDimension dimension, String key,
Map<Long, Category> categoryMap, BigDecimal income, BigDecimal expense) {
switch (dimension) {
case CATEGORY -> {
Category category = categoryMap.get(parseLong(key));
vo.setName(category != null ? category.getName() : "未分类");
vo.setIcon(category != null ? category.getIcon() : "");
vo.setType(category != null ? category.getType()
: (income.compareTo(expense) >= 0 ? "INCOME" : "EXPENSE"));
}
case PAYMENT_METHOD -> vo.setName(paymentLabel(key));
case TYPE -> {
vo.setName("INCOME".equals(key) ? "收入" : "支出");
vo.setType(key);
}
default -> vo.setName(key);
}
}
/**
* 根据维度返回 SQL 分组表达式
*
* @param dimension 统计维度
* @return 分组表达式
*/
private String groupExpression(StatisticsDimension dimension) {
return switch (dimension) {
case DAY -> "DATE_FORMAT(transaction_time, '%Y-%m-%d')";
case WEEK -> "DATE_FORMAT(transaction_time, '%x-W%v')";
case MONTH -> "DATE_FORMAT(transaction_time, '%Y-%m')";
case QUARTER -> "CONCAT(YEAR(transaction_time), '-Q', QUARTER(transaction_time))";
case YEAR -> "DATE_FORMAT(transaction_time, '%Y')";
case PAYMENT_METHOD -> "payment_method";
case CATEGORY -> "category_id";
case TYPE -> "type";
};
}
/**
* 将年月转换为时间范围查询条件
*
* @param year 年份
* @param month 月份
* @return 时间范围
*/
private StatisticsRangeQuery monthToRange(Integer year, Integer month) {
YearMonth yearMonth = YearMonth.of(year, month);
StatisticsRangeQuery range = new StatisticsRangeQuery();
range.setStartTime(yearMonth.atDay(1).atStartOfDay().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
range.setEndTime(yearMonth.atEndOfMonth().atTime(LocalTime.MAX).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
return range;
}
/**
* 解析时间字符串,兼容日期与日期时间格式
*
* @param value 时间字符串
* @param endOfDay 仅日期时是否取当天末尾
* @return 解析结果,空值返回 null
*/
private LocalDateTime parseTime(String value, boolean endOfDay) {
if (!StringUtils.hasText(value)) {
return null;
}
try {
if (value.length() == 10) {
LocalDate date = LocalDate.parse(value);
return endOfDay ? date.atTime(LocalTime.MAX) : date.atStartOfDay();
}
return LocalDateTime.parse(value, DateTimeFormatter.ISO_LOCAL_DATE_TIME);
} catch (Exception e) {
throw new BusinessException("时间格式错误:" + value);
}
}
/**
* 将支付方式编码转换为中文名称
*
* @param code 支付方式编码
* @return 中文名称
*/
private String paymentLabel(String code) {
if (!StringUtils.hasText(code)) {
return "未填写";
}
return switch (code) {
case "WECHAT" -> "微信";
case "ALIPAY" -> "支付宝";
case "CASH" -> "现金";
case "BANK" -> "银行卡";
default -> code;
};
}
/**
* 字符串转 Long,空值或非法值返回 null
*
* @param value 字符串
* @return Long 值
*/
private Long parseLong(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
try {
return new BigDecimal(value).longValue();
} catch (NumberFormatException e) {
return null;
}
}
/**
* 将查询结果值转换为字符串
*
* @param value 结果值
* @return 字符串,空值返回 null
*/
private String stringValue(Object value) {
return value == null ? null : value.toString();
}
/**
* 将查询结果值转换为 BigDecimal
*
* @param value 结果值
* @return BigDecimal,空值返回 0
*/
private BigDecimal toBigDecimal(Object value) {
if (value == null) {
return BigDecimal.ZERO;
}
if (value instanceof BigDecimal decimal) {
return decimal;
}
return new BigDecimal(value.toString());
}
/**
* 将查询结果值转换为 int
*
* @param value 结果值
* @return int 值,空值返回 0
*/
private int toInt(Object value) {
if (value == null) {
return 0;
}
return ((Number) value).intValue();
}
/**
* 安全除法,保留两位小数
*
* @param dividend 被除数
* @param divisor 除数
* @return 商,除数为 0 时返回 0
*/
private BigDecimal divide(BigDecimal dividend, int divisor) {
if (divisor <= 0) {
return BigDecimal.ZERO;
}
return dividend.divide(BigDecimal.valueOf(divisor), 2, RoundingMode.HALF_UP);
}
}
@@ -1,9 +1,11 @@
package com.example.finance.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.finance.common.BusinessException;
import com.example.finance.common.PageResult;
import com.example.finance.dto.BatchCategoryRequest;
import com.example.finance.dto.TransactionQuery;
import com.example.finance.dto.TransactionRequest;
import com.example.finance.entity.Category;
@@ -29,6 +31,9 @@ import java.util.stream.Collectors;
@Service
public class TransactionServiceImpl implements TransactionService {
/** 备注被隐藏时在导出文件中使用的占位符 */
private static final String REMARK_HIDDEN_PLACEHOLDER = "******";
private final TransactionMapper transactionMapper;
private final CategoryMapper categoryMapper;
@@ -52,9 +57,16 @@ public class TransactionServiceImpl implements TransactionService {
if (query.getTypes() != null && !query.getTypes().isEmpty()) {
wrapper.in(Transaction::getType, query.getTypes());
}
// 按分类ID筛选(多选)
if (query.getCategoryIds() != null && !query.getCategoryIds().isEmpty()) {
// 按分类ID筛选(多选);同时勾选「未分类」时与其取并集
boolean hasCategoryIds = query.getCategoryIds() != null && !query.getCategoryIds().isEmpty();
boolean uncategorized = Boolean.TRUE.equals(query.getUncategorized());
if (hasCategoryIds && uncategorized) {
wrapper.and(w -> w.in(Transaction::getCategoryId, query.getCategoryIds())
.or().isNull(Transaction::getCategoryId));
} else if (hasCategoryIds) {
wrapper.in(Transaction::getCategoryId, query.getCategoryIds());
} else if (uncategorized) {
wrapper.isNull(Transaction::getCategoryId);
}
// 按起始时间筛选
if (StringUtils.hasText(query.getStartTime())) {
@@ -74,6 +86,17 @@ public class TransactionServiceImpl implements TransactionService {
if (query.getIncludeInStats() != null) {
wrapper.eq(Transaction::getIncludeInStats, query.getIncludeInStats());
}
// 按金额区间筛选
if (query.getMinAmount() != null) {
wrapper.ge(Transaction::getAmount, query.getMinAmount());
}
if (query.getMaxAmount() != null) {
wrapper.le(Transaction::getAmount, query.getMaxAmount());
}
// 按备注关键字模糊匹配
if (StringUtils.hasText(query.getRemark())) {
wrapper.like(Transaction::getRemark, query.getRemark());
}
// 按交易时间降序排列
wrapper.orderByDesc(Transaction::getTransactionTime);
@@ -189,6 +212,96 @@ public class TransactionServiceImpl implements TransactionService {
transactionMapper.updateById(transaction);
}
/**
* {@inheritDoc}
*
* 切换单条交易记录的备注隐藏标记,用于表格中直接设置。
*/
@Override
@Transactional
public void toggleRemarkHidden(Long userId, Long id, Boolean remarkHidden) {
Transaction transaction = transactionMapper.selectById(id);
if (transaction == null || !transaction.getUserId().equals(userId)) {
throw new BusinessException("记录不存在");
}
transaction.setRemarkHidden(remarkHidden == null ? Boolean.FALSE : remarkHidden);
transactionMapper.updateById(transaction);
}
/**
* {@inheritDoc}
*
* 按交易类型分别批量设置分类:仅更新与所选分类类型一致、且属于当前用户的记录。
*/
@Override
@Transactional
public int batchUpdateCategory(Long userId, BatchCategoryRequest request) {
List<Long> ids = request.getIds();
if (ids == null || ids.isEmpty()) {
throw new BusinessException("请至少选择一条记录");
}
if (ids.size() > 1000) {
throw new BusinessException("单次最多更新1000条记录");
}
// 分类为可选,仅在提供时校验归属与类型
Category expenseCategory = request.getExpenseCategoryId() == null ? null
: requireCategory(userId, request.getExpenseCategoryId(), "EXPENSE");
Category incomeCategory = request.getIncomeCategoryId() == null ? null
: requireCategory(userId, request.getIncomeCategoryId(), "INCOME");
if (expenseCategory == null && incomeCategory == null) {
throw new BusinessException("请至少选择一个分类");
}
// 查询待更新记录,按交易类型拆分ID,避免类型不匹配的更新
List<Transaction> transactions = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.select(Transaction::getId, Transaction::getType)
.eq(Transaction::getUserId, userId)
.in(Transaction::getId, ids));
List<Long> expenseIds = transactions.stream()
.filter(t -> "EXPENSE".equals(t.getType()))
.map(Transaction::getId)
.collect(Collectors.toList());
List<Long> incomeIds = transactions.stream()
.filter(t -> "INCOME".equals(t.getType()))
.map(Transaction::getId)
.collect(Collectors.toList());
int updated = 0;
if (expenseCategory != null && !expenseIds.isEmpty()) {
updated += transactionMapper.update(null, new LambdaUpdateWrapper<Transaction>()
.set(Transaction::getCategoryId, expenseCategory.getId())
.eq(Transaction::getUserId, userId)
.in(Transaction::getId, expenseIds));
}
if (incomeCategory != null && !incomeIds.isEmpty()) {
updated += transactionMapper.update(null, new LambdaUpdateWrapper<Transaction>()
.set(Transaction::getCategoryId, incomeCategory.getId())
.eq(Transaction::getUserId, userId)
.in(Transaction::getId, incomeIds));
}
return updated;
}
/**
* 校验分类是否存在、归属当前用户且类型匹配
*
* @param userId 用户ID
* @param categoryId 分类ID
* @param type 期望的分类类型
* @return 分类实体
*/
private Category requireCategory(Long userId, Long categoryId, String type) {
Category category = categoryMapper.selectById(categoryId);
if (category == null || !category.getUserId().equals(userId)) {
throw new BusinessException("分类不存在");
}
if (!type.equals(category.getType())) {
throw new BusinessException("分类与交易类型不匹配");
}
return category;
}
/**
* 将交易实体和关联分类转换为视图对象
*
@@ -206,6 +319,7 @@ public class TransactionServiceImpl implements TransactionService {
vo.setAmount(t.getAmount());
vo.setPaymentMethod(t.getPaymentMethod());
vo.setRemark(t.getRemark());
vo.setRemarkHidden(t.getRemarkHidden() == null ? Boolean.FALSE : t.getRemarkHidden());
vo.setTransactionTime(t.getTransactionTime());
vo.setIncludeInStats(t.getIncludeInStats() == null ? Boolean.TRUE : t.getIncludeInStats());
vo.setCreatedAt(t.getCreatedAt());
@@ -226,9 +340,16 @@ public class TransactionServiceImpl implements TransactionService {
if (query.getTypes() != null && !query.getTypes().isEmpty()) {
wrapper.in(Transaction::getType, query.getTypes());
}
// 按分类ID筛选(多选)
if (query.getCategoryIds() != null && !query.getCategoryIds().isEmpty()) {
// 按分类ID筛选(多选);同时勾选「未分类」时与其取并集
boolean hasCategoryIds = query.getCategoryIds() != null && !query.getCategoryIds().isEmpty();
boolean uncategorized = Boolean.TRUE.equals(query.getUncategorized());
if (hasCategoryIds && uncategorized) {
wrapper.and(w -> w.in(Transaction::getCategoryId, query.getCategoryIds())
.or().isNull(Transaction::getCategoryId));
} else if (hasCategoryIds) {
wrapper.in(Transaction::getCategoryId, query.getCategoryIds());
} else if (uncategorized) {
wrapper.isNull(Transaction::getCategoryId);
}
// 按起始时间筛选
if (StringUtils.hasText(query.getStartTime())) {
@@ -248,6 +369,17 @@ public class TransactionServiceImpl implements TransactionService {
if (query.getIncludeInStats() != null) {
wrapper.eq(Transaction::getIncludeInStats, query.getIncludeInStats());
}
// 按金额区间筛选
if (query.getMinAmount() != null) {
wrapper.ge(Transaction::getAmount, query.getMinAmount());
}
if (query.getMaxAmount() != null) {
wrapper.le(Transaction::getAmount, query.getMaxAmount());
}
// 按备注关键字模糊匹配
if (StringUtils.hasText(query.getRemark())) {
wrapper.like(Transaction::getRemark, query.getRemark());
}
// 按交易时间降序排列
wrapper.orderByDesc(Transaction::getTransactionTime);
@@ -291,7 +423,12 @@ public class TransactionServiceImpl implements TransactionService {
vo.setCategoryName(category != null ? category.getName() : "");
vo.setAmount(String.format("%.2f", t.getAmount()));
vo.setPaymentMethod(getPaymentMethodLabel(t.getPaymentMethod()));
vo.setRemark(t.getRemark() != null ? t.getRemark() : "");
// 备注被标记为隐藏时,导出同样以占位符替代,避免泄露
if (Boolean.TRUE.equals(t.getRemarkHidden())) {
vo.setRemark(REMARK_HIDDEN_PLACEHOLDER);
} else {
vo.setRemark(t.getRemark() != null ? t.getRemark() : "");
}
vo.setTransactionTime(t.getTransactionTime() != null
? t.getTransactionTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
: "");
@@ -0,0 +1,29 @@
package com.example.finance.vo;
import lombok.Data;
import java.util.List;
/**
* 账单导入结果视图对象,用于「后导入」阶段返回导入统计。
*
* @date 2026-09-17 09:50:03
*/
@Data
public class BillImportResultVO {
/** 提交导入的行总数 */
private int total;
/** 成功导入数 */
private int imported;
/** 因重复而跳过数 */
private int skipped;
/** 因校验失败而失败数 */
private int failed;
/** 失败或跳过的明细说明 */
private List<String> messages;
}
@@ -0,0 +1,24 @@
package com.example.finance.vo;
import com.example.finance.importer.ParsedBillRow;
import lombok.Data;
import java.util.List;
/**
* 账单解析结果视图对象,用于「先解析」阶段向前端返回预览数据。
*
* @date 2026-09-17 09:50:02
*/
@Data
public class BillParseResultVO {
/** 账单类型 */
private String billType;
/** 解析出的账单行总数 */
private int total;
/** 解析出的账单行列表 */
private List<ParsedBillRow> rows;
}
@@ -0,0 +1,41 @@
package com.example.finance.vo;
import lombok.Data;
import java.math.BigDecimal;
/**
* 分组统计视图对象,用于展示按某一维度分组后的收支数据。
*
* @date 2026-09-17 10:50:32
*/
@Data
public class StatisticsGroupVO {
/** 分组键(如分类ID、支付方式编码、日期标签) */
private String key;
/** 分组显示名称 */
private String name;
/** 分类图标(仅分类维度有值) */
private String icon;
/** 交易类型(仅分类维度有值) */
private String type;
/** 该分组收入金额 */
private BigDecimal income;
/** 该分组支出金额 */
private BigDecimal expense;
/** 该分组总金额(收入+支出) */
private BigDecimal total;
/** 该分组交易笔数 */
private Integer count;
/** 占总金额百分比(非时间维度有效) */
private BigDecimal percentage;
}
@@ -0,0 +1,48 @@
package com.example.finance.vo;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 统计概览视图对象,用于展示指定时间范围内的收支汇总与派生指标。
*
* @date 2026-09-17 10:50:32
*/
@Data
public class StatisticsSummaryVO {
/** 总收入 */
private BigDecimal totalIncome;
/** 总支出 */
private BigDecimal totalExpense;
/** 结余(收入减支出) */
private BigDecimal balance;
/** 交易笔数 */
private Integer transactionCount;
/** 统计区间天数 */
private Integer dayCount;
/** 日均收入 */
private BigDecimal avgDailyIncome;
/** 日均支出 */
private BigDecimal avgDailyExpense;
/** 最大单笔支出金额 */
private BigDecimal maxExpenseAmount;
/** 最大单笔支出发生时间 */
private LocalDateTime maxExpenseTime;
/** 最大单笔支出备注 */
private String maxExpenseRemark;
/** 平均每笔支出 */
private BigDecimal avgExpenseAmount;
}
@@ -35,6 +35,9 @@ public class TransactionVO {
/** 备注 */
private String remark;
/** 备注是否隐藏:true-隐藏(前端显示占位),false-显示 */
private Boolean remarkHidden;
/** 交易时间 */
private LocalDateTime transactionTime;
+4
View File
@@ -1,6 +1,10 @@
spring:
application:
name: my-app
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
+5 -2
View File
@@ -25,11 +25,13 @@ CREATE TABLE IF NOT EXISTS t_category (
CREATE TABLE IF NOT EXISTS t_transaction (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID,主键自增',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
category_id BIGINT NOT NULL COMMENT '所属分类ID',
category_id BIGINT COMMENT '所属分类ID,可为空表示未分类',
type VARCHAR(10) NOT NULL COMMENT '记录类型:INCOME-收入,EXPENSE-支出',
amount DECIMAL(12, 2) NOT NULL COMMENT '金额,精确到分',
payment_method VARCHAR(30) COMMENT '支付方式:WECHAT-微信,ALIPAY-支付宝,CASH-现金,BANK-银行卡',
remark VARCHAR(255) COMMENT '备注说明',
remark_hidden TINYINT(1) NOT NULL DEFAULT 0 COMMENT '备注是否隐藏:1-隐藏,0-显示',
external_no VARCHAR(64) COMMENT '外部账单交易号,用于账单导入去重',
transaction_time DATETIME NOT NULL COMMENT '交易发生时间',
include_in_stats TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否计入统计:1-计入,0-不计入(如借款、转账等)',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
@@ -37,5 +39,6 @@ CREATE TABLE IF NOT EXISTS t_transaction (
INDEX idx_user_id (user_id) COMMENT '用户ID索引',
INDEX idx_category_id (category_id) COMMENT '分类ID索引',
INDEX idx_transaction_time (transaction_time) COMMENT '交易时间索引',
INDEX idx_user_time (user_id, transaction_time) COMMENT '用户ID+交易时间联合索引'
INDEX idx_user_time (user_id, transaction_time) COMMENT '用户ID+交易时间联合索引',
UNIQUE INDEX uk_user_external (user_id, external_no) COMMENT '用户ID+外部交易号唯一索引,用于导入去重'
);
@@ -0,0 +1,44 @@
-- 初始化默认收支分类种子脚本
-- 用途:为现有用户补齐一套默认分类(支出 10 个、收入 6 个)
-- 特性:幂等,按「用户ID + 分类名称 + 分类类型」去重,可重复执行
-- 编码:utf8mb4(图标使用 emoji,需以 --default-character-set=utf8mb4 执行)
INSERT INTO t_category (user_id, name, type, icon, sort_order)
SELECT u.id, d.name, d.type, d.icon, d.sort_order
FROM t_user u
CROSS JOIN (
SELECT '餐饮' AS name, 'EXPENSE' AS type, '🍜' AS icon, 10 AS sort_order
UNION ALL SELECT '交通', 'EXPENSE', '🚌', 20
UNION ALL SELECT '购物', 'EXPENSE', '🛍️', 30
UNION ALL SELECT '居住', 'EXPENSE', '🏠', 40
UNION ALL SELECT '通讯', 'EXPENSE', '📱', 50
UNION ALL SELECT '娱乐', 'EXPENSE', '🎮', 60
UNION ALL SELECT '医疗', 'EXPENSE', '💊', 70
UNION ALL SELECT '教育', 'EXPENSE', '📚', 80
UNION ALL SELECT '美容美发', 'EXPENSE', '💇', 85
UNION ALL SELECT '人情往来', 'EXPENSE', '🎁', 90
UNION ALL SELECT '其他支出', 'EXPENSE', '📦', 100
UNION ALL SELECT '工资', 'INCOME', '💰', 10
UNION ALL SELECT '奖金', 'INCOME', '🧧', 20
UNION ALL SELECT '兼职', 'INCOME', '💼', 30
UNION ALL SELECT '投资收益', 'INCOME', '📈', 40
UNION ALL SELECT '退款', 'INCOME', '↩️', 50
UNION ALL SELECT '其他收入', 'INCOME', '📦', 60
) d
WHERE NOT EXISTS (
SELECT 1 FROM t_category c
WHERE c.user_id = u.id AND c.name = d.name AND c.type = d.type
);
-- 备选写法:若 MySQL 对上述 INSERT ... SELECT 引用目标表报错 1093,
-- 可改用临时表方式:
-- CREATE TEMPORARY TABLE tmp_default_category (
-- name VARCHAR(50), type VARCHAR(10), icon VARCHAR(50), sort_order INT
-- );
-- INSERT INTO tmp_default_category VALUES ('餐饮','EXPENSE','🍜',10), ...;
-- INSERT INTO t_category (user_id, name, type, icon, sort_order)
-- SELECT u.id, d.name, d.type, d.icon, d.sort_order
-- FROM t_user u JOIN tmp_default_category d
-- WHERE NOT EXISTS (SELECT 1 FROM t_category c
-- WHERE c.user_id = u.id AND c.name = d.name AND c.type = d.type);
-- DROP TEMPORARY TABLE tmp_default_category;