feat: 新增标签体系、微信账单导入与收支记录体验优化
- 标签:新增 t_tag / t_transaction_tag 表与标签 CRUD,支持交易打标签、按标签筛选、批量添加/移除标签、导入账单默认标签,新增标签管理页 - 账单导入:新增微信支付 xlsx 解析器(WechatBillParser),支持支付宝 / 微信按类型导入;解析行保存原始完整数据(raw_data)并可在列表查看 - 收支记录:排序移至顶部并改为服务端排序(交易时间 / 金额,记忆偏好);筛选条件收纳为「更多筛选」弹层;分类筛选显示图标;一键设置支持「计入统计」;导入预览支持关键字 / 类型 / 状态 / 金额筛选 - 数据库:init.sql 新增 raw_data 字段与标签表,新增 init_tags.sql 默认标签种子 - 其他:更新 README 测试账号
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
// 标签相关 API 接口:标签的增删改查
|
||||
import request from './request'
|
||||
|
||||
/** 获取标签列表 */
|
||||
export function getTagList() {
|
||||
return request.get('/tag/list')
|
||||
}
|
||||
|
||||
/** 创建新标签 */
|
||||
export function createTag(data: { name: string; color?: string; sortOrder?: number }) {
|
||||
return request.post('/tag', data)
|
||||
}
|
||||
|
||||
/** 更新指定标签 */
|
||||
export function updateTag(id: number, data: { name: string; color?: string; sortOrder?: number }) {
|
||||
return request.put(`/tag/${id}`, data)
|
||||
}
|
||||
|
||||
/** 删除指定标签 */
|
||||
export function deleteTag(id: number) {
|
||||
return request.delete(`/tag/${id}`)
|
||||
}
|
||||
@@ -17,6 +17,9 @@ export function getTransactionPage(params: {
|
||||
minAmount?: number
|
||||
maxAmount?: number
|
||||
remark?: string
|
||||
sortField?: string
|
||||
sortOrder?: string
|
||||
tagIds?: number[]
|
||||
}) {
|
||||
return request.get('/transaction/page', { params })
|
||||
}
|
||||
@@ -30,6 +33,7 @@ export function createTransaction(data: {
|
||||
remark?: string
|
||||
transactionTime: string
|
||||
includeInStats?: boolean
|
||||
tagIds?: number[]
|
||||
}) {
|
||||
return request.post('/transaction', data)
|
||||
}
|
||||
@@ -43,6 +47,7 @@ export function updateTransaction(id: number, data: {
|
||||
remark?: string
|
||||
transactionTime: string
|
||||
includeInStats?: boolean
|
||||
tagIds?: number[]
|
||||
}) {
|
||||
return request.put(`/transaction/${id}`, data)
|
||||
}
|
||||
@@ -62,8 +67,8 @@ export function toggleRemarkHidden(id: number, remarkHidden: boolean) {
|
||||
return request.put(`/transaction/${id}/remark-hidden`, { remarkHidden })
|
||||
}
|
||||
|
||||
/** 支持的账单类型,当前仅支付宝 */
|
||||
export type BillType = 'ALIPAY'
|
||||
/** 支持的账单类型,当前支持支付宝与微信 */
|
||||
export type BillType = 'ALIPAY' | 'WECHAT'
|
||||
|
||||
/** 账单解析结果行 */
|
||||
export interface ParsedBillRow {
|
||||
@@ -80,6 +85,7 @@ export interface ParsedBillRow {
|
||||
importable: boolean
|
||||
selected: boolean
|
||||
duplicate: boolean
|
||||
rawData: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
@@ -92,6 +98,7 @@ export interface BillImportItem {
|
||||
counterparty: string
|
||||
product: string
|
||||
includeInStats: boolean
|
||||
rawData: string | null
|
||||
}
|
||||
|
||||
/** 解析账单文件(不落库),返回预览数据 */
|
||||
@@ -117,10 +124,20 @@ export function batchUpdateCategory(data: {
|
||||
ids: number[]
|
||||
expenseCategoryId?: number | null
|
||||
incomeCategoryId?: number | null
|
||||
includeInStats?: boolean | null
|
||||
}) {
|
||||
return request.put('/transaction/batch-category', data)
|
||||
}
|
||||
|
||||
/** 批量给交易记录添加/移除标签 */
|
||||
export function batchTags(data: {
|
||||
ids: number[]
|
||||
addTagIds?: number[]
|
||||
removeTagIds?: number[]
|
||||
}) {
|
||||
return request.put('/transaction/batch-tags', data)
|
||||
}
|
||||
|
||||
/** 导出交易记录为 Excel 文件,支持按条件筛选 */
|
||||
export function exportTransaction(params: {
|
||||
types?: string[]
|
||||
@@ -133,6 +150,9 @@ export function exportTransaction(params: {
|
||||
minAmount?: number
|
||||
maxAmount?: number
|
||||
remark?: string
|
||||
sortField?: string
|
||||
sortOrder?: string
|
||||
tagIds?: number[]
|
||||
}) {
|
||||
const userStore = useUserStore()
|
||||
return axios.get('/api/transaction/export', {
|
||||
|
||||
@@ -36,6 +36,11 @@ const router = createRouter({
|
||||
name: 'Categories',
|
||||
component: () => import('../views/CategoryManage.vue'), // 分类管理
|
||||
},
|
||||
{
|
||||
path: 'tags',
|
||||
name: 'Tags',
|
||||
component: () => import('../views/TagManage.vue'), // 标签管理
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import { ref, computed, h, watch } from 'vue'
|
||||
import {
|
||||
useMessage, NModal, NSpace, NButton, NSelect, NUpload, NUploadDragger, NDataTable,
|
||||
NCheckbox, NTag, NAlert, NText, NIcon,
|
||||
NCheckbox, NTag, NAlert, NText, NIcon, NInput, NInputNumber,
|
||||
} 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'
|
||||
import { getTagList } from '../api/tag'
|
||||
|
||||
const props = defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
@@ -19,9 +20,47 @@ const emit = defineEmits<{
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
// 账单类型选项,当前仅支持支付宝
|
||||
const billType = ref<BillType>('ALIPAY')
|
||||
const billTypeOptions = [{ label: '支付宝', value: 'ALIPAY' }]
|
||||
// 账单类型偏好存储键
|
||||
const BILL_TYPE_PREFS_KEY = 'finance.billImport.billType'
|
||||
|
||||
// 账单类型选项,当前支持支付宝与微信
|
||||
const billTypeOptions = [
|
||||
{ label: '支付宝', value: 'ALIPAY' },
|
||||
{ label: '微信', value: 'WECHAT' },
|
||||
]
|
||||
|
||||
/** 读取上次选择的账单类型偏好,非法值回退为支付宝 */
|
||||
function loadBillTypePref(): BillType {
|
||||
const saved = localStorage.getItem(BILL_TYPE_PREFS_KEY)
|
||||
return saved === 'ALIPAY' || saved === 'WECHAT' ? saved : 'ALIPAY'
|
||||
}
|
||||
|
||||
// 当前账单类型,初始值取上次选择(个人偏好)
|
||||
const billType = ref<BillType>(loadBillTypePref())
|
||||
|
||||
// 各账单类型允许上传的文件扩展名
|
||||
const allowedExtensions: Record<BillType, string[]> = {
|
||||
ALIPAY: ['.csv'],
|
||||
WECHAT: ['.xlsx'],
|
||||
}
|
||||
|
||||
// 各账单类型的账单导出路径提示
|
||||
const exportHints: Record<BillType, string> = {
|
||||
ALIPAY: '导出路径:支付宝 App → 我的 → 账单 → 开具交易流水证明 / 账单导出,选择 CSV 格式。',
|
||||
WECHAT: '导出路径:微信 App → 我 → 服务 → 钱包 → 账单 → 常见问题 → 下载账单 → 用于个人对账,选择 xlsx 格式。',
|
||||
}
|
||||
|
||||
// 当前账单类型允许的扩展名,用于上传组件 accept 属性
|
||||
const acceptTypes = computed(() => (allowedExtensions[billType.value] || []).join(','))
|
||||
|
||||
// 当前账单类型的文件格式提示
|
||||
const fileHint = computed(() => {
|
||||
const label = billTypeOptions.find((o) => o.value === billType.value)?.label || ''
|
||||
return `仅支持${label}导出的账单文件(${(allowedExtensions[billType.value] || []).join('、')})`
|
||||
})
|
||||
|
||||
// 当前账单类型的导出路径提示
|
||||
const exportHint = computed(() => exportHints[billType.value])
|
||||
|
||||
// 弹窗阶段:选择文件 -> 预览勾选 -> 导入结果
|
||||
const stage = ref<'select' | 'preview' | 'result'>('select')
|
||||
@@ -35,25 +74,83 @@ const importing = ref(false)
|
||||
const rows = ref<ParsedBillRow[]>([])
|
||||
const checkedRowNos = ref<number[]>([])
|
||||
|
||||
// 预览筛选条件
|
||||
const filterKeyword = ref('')
|
||||
const filterType = ref<string | null>(null)
|
||||
const filterStatus = ref<string | null>(null)
|
||||
const filterMinAmount = ref<number | null>(null)
|
||||
const filterMaxAmount = ref<number | null>(null)
|
||||
|
||||
// 类型筛选选项
|
||||
const typeFilterOptions = [
|
||||
{ label: '收入', value: 'INCOME' },
|
||||
{ label: '支出', value: 'EXPENSE' },
|
||||
{ label: '不计入', value: 'NEUTRAL' },
|
||||
]
|
||||
|
||||
// 状态筛选选项
|
||||
const statusFilterOptions = [
|
||||
{ label: '可导入', value: 'importable' },
|
||||
{ label: '已存在', value: 'duplicate' },
|
||||
{ label: '不可导入', value: 'error' },
|
||||
]
|
||||
|
||||
/** 重置预览筛选条件 */
|
||||
function resetFilters() {
|
||||
filterKeyword.value = ''
|
||||
filterType.value = null
|
||||
filterStatus.value = null
|
||||
filterMinAmount.value = null
|
||||
filterMaxAmount.value = null
|
||||
}
|
||||
|
||||
// 默认分类
|
||||
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 allTags = ref<{ label: string; value: number }[]>([])
|
||||
const importTagIds = ref<number[]>([])
|
||||
|
||||
// 导入结果
|
||||
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 filteredRows = computed(() => {
|
||||
const kw = filterKeyword.value.trim().toLowerCase()
|
||||
return rows.value.filter((r) => {
|
||||
if (kw) {
|
||||
const text = `${r.counterparty || ''} ${r.product || ''} ${r.externalNo || ''}`.toLowerCase()
|
||||
if (!text.includes(kw)) return false
|
||||
}
|
||||
if (filterType.value === 'NEUTRAL') {
|
||||
if (r.includeInStats) return false
|
||||
} else if (filterType.value && r.type !== filterType.value) {
|
||||
return false
|
||||
}
|
||||
if (filterStatus.value === 'importable' && !r.importable) return false
|
||||
if (filterStatus.value === 'duplicate' && !r.duplicate) return false
|
||||
if (filterStatus.value === 'error' && !r.error) return false
|
||||
if (filterMinAmount.value != null && Number(r.amount) < filterMinAmount.value) return false
|
||||
if (filterMaxAmount.value != null && Number(r.amount) > filterMaxAmount.value) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
/** 可导入的行(受筛选影响) */
|
||||
const selectableRows = computed(() => filteredRows.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 someChecked = computed(() => {
|
||||
const checkedVisible = selectableRows.value.filter((r) => checkedRowNos.value.includes(r.rowNo))
|
||||
return checkedVisible.length > 0 && !allChecked.value
|
||||
})
|
||||
|
||||
/** 表格列定义 */
|
||||
const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
|
||||
@@ -136,10 +233,34 @@ async function loadCategories() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 文件选择变化(支持点击或拖拽) */
|
||||
/** 加载标签选项 */
|
||||
async function loadTags() {
|
||||
try {
|
||||
const res: any = await getTagList()
|
||||
allTags.value = (res.data || []).map((t: any) => ({ label: t.name, value: t.id }))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '加载标签失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断文件名是否符合当前账单类型允许的扩展名 */
|
||||
function isAllowedFile(name: string) {
|
||||
const exts = allowedExtensions[billType.value] || []
|
||||
const lower = name.toLowerCase()
|
||||
return exts.length === 0 || exts.some((ext) => lower.endsWith(ext))
|
||||
}
|
||||
|
||||
/** 文件选择变化(支持点击或拖拽),按账单类型校验扩展名,非法文件直接拒绝 */
|
||||
function handleFileChange(options: { fileList: UploadFileInfo[] }) {
|
||||
uploadFileList.value = options.fileList
|
||||
const last = options.fileList[options.fileList.length - 1]
|
||||
if (last?.file && !isAllowedFile(last.name)) {
|
||||
const label = billTypeOptions.find((o) => o.value === billType.value)?.label || billType.value
|
||||
message.error(`账单类型「${label}」仅支持 ${allowedExtensions[billType.value].join('、')} 格式的文件`)
|
||||
uploadFileList.value = []
|
||||
file.value = null
|
||||
return
|
||||
}
|
||||
uploadFileList.value = options.fileList
|
||||
file.value = last?.file ?? null
|
||||
}
|
||||
|
||||
@@ -155,6 +276,7 @@ async function handleParse() {
|
||||
rows.value = res.data.rows || []
|
||||
// 默认勾选解析器建议勾选的行
|
||||
checkedRowNos.value = rows.value.filter((r) => r.selected).map((r) => r.rowNo)
|
||||
resetFilters()
|
||||
stage.value = 'preview'
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '解析失败')
|
||||
@@ -193,6 +315,7 @@ async function handleConfirm() {
|
||||
counterparty: r.counterparty,
|
||||
product: r.product,
|
||||
includeInStats: r.includeInStats,
|
||||
rawData: r.rawData,
|
||||
}))
|
||||
importing.value = true
|
||||
try {
|
||||
@@ -200,6 +323,7 @@ async function handleConfirm() {
|
||||
billType: billType.value,
|
||||
expenseCategoryId: expenseCategoryId.value,
|
||||
incomeCategoryId: incomeCategoryId.value,
|
||||
tagIds: importTagIds.value,
|
||||
items,
|
||||
})
|
||||
result.value = res.data
|
||||
@@ -222,6 +346,8 @@ function reset() {
|
||||
result.value = null
|
||||
expenseCategoryId.value = null
|
||||
incomeCategoryId.value = null
|
||||
importTagIds.value = []
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
/** 关闭弹窗 */
|
||||
@@ -229,6 +355,13 @@ function handleClose() {
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
// 切换账单类型时清空已选文件、记录偏好,避免文件格式与类型不匹配
|
||||
watch(billType, (value) => {
|
||||
uploadFileList.value = []
|
||||
file.value = null
|
||||
localStorage.setItem(BILL_TYPE_PREFS_KEY, value)
|
||||
})
|
||||
|
||||
// 打开弹窗时重置并加载分类
|
||||
watch(
|
||||
() => props.show,
|
||||
@@ -236,6 +369,7 @@ watch(
|
||||
if (visible) {
|
||||
reset()
|
||||
loadCategories()
|
||||
loadTags()
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -264,7 +398,7 @@ watch(
|
||||
:file-list="uploadFileList"
|
||||
:default-upload="false"
|
||||
:max="1"
|
||||
accept=".csv"
|
||||
:accept="acceptTypes"
|
||||
style="margin-top: 8px;"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
@@ -275,7 +409,7 @@ watch(
|
||||
</NIcon>
|
||||
<div style="font-size: 15px; margin-top: 8px;">点击或拖拽文件到此处</div>
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">
|
||||
仅支持支付宝导出的交易记录明细 .csv 文件
|
||||
{{ fileHint }}
|
||||
</div>
|
||||
</div>
|
||||
</NUploadDragger>
|
||||
@@ -283,7 +417,7 @@ watch(
|
||||
</div>
|
||||
|
||||
<NAlert type="info" :show-icon="true">
|
||||
导出路径:支付宝 App → 我的 → 账单 → 开具交易流水证明 / 账单导出,选择 CSV 格式。
|
||||
{{ exportHint }}
|
||||
</NAlert>
|
||||
|
||||
<NSpace justify="end">
|
||||
@@ -315,12 +449,42 @@ watch(
|
||||
placeholder="未选则不计分类"
|
||||
style="width: 180px;"
|
||||
/>
|
||||
<NText depth="3">共 {{ rows.length }} 条,已勾选 {{ checkedRows.length }} 条</NText>
|
||||
<NText>默认标签(可不选):</NText>
|
||||
<NSelect
|
||||
v-model:value="importTagIds"
|
||||
:options="allTags"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
:max-tag-count="2"
|
||||
placeholder="未选则不打标签"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<NCheckbox
|
||||
:checked="allChecked"
|
||||
:indeterminate="someChecked"
|
||||
:disabled="selectableRows.length === 0"
|
||||
@update:checked="toggleAll"
|
||||
>
|
||||
全选
|
||||
</NCheckbox>
|
||||
<NText depth="3">共 {{ rows.length }} 条,筛选后 {{ filteredRows.length }} 条,已勾选 {{ checkedRows.length }} 条</NText>
|
||||
</NSpace>
|
||||
|
||||
<!-- 筛选条件 -->
|
||||
<NSpace align="center" :wrap="true" :size="[12, 12]">
|
||||
<NInput v-model:value="filterKeyword" clearable placeholder="搜索交易对方/商品/单号" style="width: 220px;" />
|
||||
<NSelect v-model:value="filterType" :options="typeFilterOptions" clearable placeholder="类型" style="width: 120px;" />
|
||||
<NSelect v-model:value="filterStatus" :options="statusFilterOptions" clearable placeholder="状态" style="width: 130px;" />
|
||||
<NInputNumber v-model:value="filterMinAmount" :min="0" :precision="2" clearable placeholder="最小金额" style="width: 120px;" />
|
||||
<span style="color: #999;">~</span>
|
||||
<NInputNumber v-model:value="filterMaxAmount" :min="0" :precision="2" clearable placeholder="最大金额" style="width: 120px;" />
|
||||
<NButton size="small" @click="resetFilters">重置</NButton>
|
||||
</NSpace>
|
||||
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:data="filteredRows"
|
||||
:max-height="420"
|
||||
:row-key="(row) => row.rowNo"
|
||||
size="small"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { h, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NButton, NSpace, NIcon } from 'naive-ui'
|
||||
import type { MenuOption } from 'naive-ui'
|
||||
import { BarChartOutline, ListOutline, FolderOpenOutline, LogOutOutline } from '@vicons/ionicons5'
|
||||
import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, LogOutOutline } from '@vicons/ionicons5'
|
||||
import { useUserStore } from '../store/user'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -17,6 +17,7 @@ const activeKey = computed(() => {
|
||||
'/dashboard': 'dashboard',
|
||||
'/transactions': 'transactions',
|
||||
'/categories': 'categories',
|
||||
'/tags': 'tags',
|
||||
}
|
||||
return map[route.path] || 'dashboard'
|
||||
})
|
||||
@@ -31,6 +32,7 @@ const menuOptions: MenuOption[] = [
|
||||
{ label: '统计面板', key: 'dashboard', icon: renderIcon(BarChartOutline) },
|
||||
{ label: '收支记录', key: 'transactions', icon: renderIcon(ListOutline) },
|
||||
{ label: '分类管理', key: 'categories', icon: renderIcon(FolderOpenOutline) },
|
||||
{ label: '标签管理', key: 'tags', icon: renderIcon(PricetagsOutline) },
|
||||
]
|
||||
|
||||
/** 菜单点击事件:根据 key 导航到对应路由 */
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
// 标签管理组件:展示标签列表,支持新增、编辑、删除标签
|
||||
import { ref, onMounted, h } from 'vue'
|
||||
import { useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem, NInput, NInputNumber, NColorPicker, NTag } from 'naive-ui'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { getTagList, createTag, updateTag, deleteTag } from '../api/tag'
|
||||
|
||||
const message = useMessage() // 消息提示
|
||||
const dialog = useDialog() // 对话框
|
||||
|
||||
/** 标签数据结构 */
|
||||
interface TagItem {
|
||||
id: number
|
||||
name: string
|
||||
color: string
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// 列表和弹窗状态
|
||||
const tags = ref<TagItem[]>([]) // 标签列表
|
||||
const loading = ref(false) // 列表加载状态
|
||||
const showModal = ref(false) // 新增/编辑弹窗显示状态
|
||||
const editingId = ref<number | null>(null) // 当前编辑的标签 ID(null 表示新增)
|
||||
const form = ref<{ name: string; color: string; sortOrder: number }>({ name: '', color: '#18a058', sortOrder: 0 }) // 表单数据
|
||||
|
||||
/** 创建表格列配置 */
|
||||
function createColumns(): DataTableColumns<TagItem> {
|
||||
return [
|
||||
{
|
||||
title: '标签', key: 'name',
|
||||
render(row) {
|
||||
return h(NTag, { color: { color: row.color || '#2080f0', textColor: '#fff' } }, { default: () => row.name })
|
||||
},
|
||||
},
|
||||
{ title: '颜色', key: 'color', width: 140 },
|
||||
{ title: '排序', key: 'sortOrder', width: 80 },
|
||||
{
|
||||
title: '操作', key: 'actions', width: 180,
|
||||
render(row) {
|
||||
return h(NSpace, null, {
|
||||
default: () => [
|
||||
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => '编辑' }),
|
||||
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => '删除' }),
|
||||
],
|
||||
})
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const columns = createColumns() // 表格列配置
|
||||
|
||||
/** 加载标签列表数据 */
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await getTagList()
|
||||
tags.value = res.data
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开新增标签弹窗,重置表单 */
|
||||
function handleAdd() {
|
||||
editingId.value = null
|
||||
form.value = { name: '', color: '#18a058', sortOrder: 0 }
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
/** 打开编辑标签弹窗,填充表单数据 */
|
||||
function handleEdit(row: TagItem) {
|
||||
editingId.value = row.id
|
||||
form.value = { name: row.name, color: row.color || '#18a058', sortOrder: row.sortOrder }
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
/** 提交表单:校验 -> 新增或更新 -> 刷新列表 */
|
||||
async function handleSubmit() {
|
||||
if (!form.value.name) {
|
||||
message.warning('请输入标签名称')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateTag(editingId.value, form.value)
|
||||
message.success('修改成功')
|
||||
} else {
|
||||
await createTag(form.value)
|
||||
message.success('创建成功')
|
||||
}
|
||||
showModal.value = false
|
||||
await fetchData()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除标签:弹出确认对话框,确认后执行删除并刷新列表 */
|
||||
function handleDelete(id: number) {
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
content: '删除后将从所有交易记录中移除该标签,确定删除吗?',
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await deleteTag(id)
|
||||
message.success('删除成功')
|
||||
await fetchData()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 组件挂载时加载标签数据
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 顶部:标题和新增按钮 -->
|
||||
<NSpace justify="space-between" style="margin-bottom: 16px;">
|
||||
<h2 style="margin: 0;">标签管理</h2>
|
||||
<NButton type="primary" @click="handleAdd">新增标签</NButton>
|
||||
</NSpace>
|
||||
|
||||
<!-- 标签数据表格 -->
|
||||
<NDataTable :columns="columns" :data="tags" :loading="loading" :bordered="false" />
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<NModal v-model:show="showModal" preset="card" :title="editingId ? '编辑标签' : '新增标签'" style="width: 450px;">
|
||||
<NForm>
|
||||
<NFormItem label="标签名称">
|
||||
<NInput v-model:value="form.name" placeholder="请输入标签名称" />
|
||||
</NFormItem>
|
||||
<NFormItem label="颜色">
|
||||
<NColorPicker v-model:value="form.color" />
|
||||
</NFormItem>
|
||||
<NFormItem label="排序">
|
||||
<NInputNumber v-model:value="form.sortOrder" :min="0" />
|
||||
</NFormItem>
|
||||
<NButton type="primary" block @click="handleSubmit">确定</NButton>
|
||||
</NForm>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
// 收支记录管理组件:展示交易记录列表(分页),支持新增、编辑、删除、多条件筛选
|
||||
import { ref, onMounted, computed, h, watch } from 'vue'
|
||||
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon } from 'naive-ui'
|
||||
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover } from 'naive-ui'
|
||||
import { EyeOutline, EyeOffOutline } from '@vicons/ionicons5'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory } from '../api/transaction'
|
||||
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags } from '../api/transaction'
|
||||
import { getCategoryList } from '../api/category'
|
||||
import { getTagList } from '../api/tag'
|
||||
import BillImportModal from './BillImportModal.vue'
|
||||
|
||||
const message = useMessage() // 消息提示
|
||||
@@ -24,6 +25,8 @@ interface TransactionItem {
|
||||
remarkHidden: boolean
|
||||
transactionTime: string
|
||||
includeInStats: boolean
|
||||
rawData: string | null
|
||||
tags: TagOption[]
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -35,11 +38,27 @@ interface CategoryOption {
|
||||
icon: string
|
||||
}
|
||||
|
||||
/** 标签数据结构 */
|
||||
interface TagOption {
|
||||
id: number
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
// 列表数据和分页状态
|
||||
const transactions = ref<TransactionItem[]>([]) // 交易记录列表
|
||||
const total = ref(0) // 总记录数
|
||||
const page = ref(1) // 当前页码
|
||||
const pageSize = ref(20) // 每页条数
|
||||
const sortValue = ref('transactionTime-desc') // 排序方式:排序字段-方向
|
||||
const sortOptions = [
|
||||
{ label: '交易时间(新→旧)', value: 'transactionTime-desc' },
|
||||
{ label: '交易时间(旧→新)', value: 'transactionTime-asc' },
|
||||
{ label: '金额(高→低)', value: 'amount-desc' },
|
||||
{ label: '金额(低→高)', value: 'amount-asc' },
|
||||
]
|
||||
const sortField = computed(() => sortValue.value.split('-')[0]) // 服务端排序字段
|
||||
const sortOrder = computed(() => sortValue.value.split('-')[1]) // 服务端排序方向:asc/desc
|
||||
const pageSizes = [10, 20, 50, 100, 200, 500] // 可选的每页条数
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value))) // 总页数
|
||||
// 当前页金额汇总:支出、收入与结余(含本页所有展示记录)
|
||||
@@ -57,11 +76,24 @@ const loading = ref(false) // 列表加载状态
|
||||
const showModal = ref(false) // 新增/编辑弹窗显示状态
|
||||
const showImportModal = ref(false) // 账单导入弹窗显示状态
|
||||
const showBatchModal = ref(false) // 批量设置分类弹窗显示状态
|
||||
const showRawModal = ref(false) // 原始账单数据弹窗显示状态
|
||||
const rawDataText = ref('') // 格式化后的原始账单数据文本
|
||||
const batchExpenseCategoryId = ref<number | null>(null) // 批量设置的支出分类
|
||||
const batchIncomeCategoryId = ref<number | null>(null) // 批量设置的收入分类
|
||||
const batchIncludeInStats = ref<'null' | 'true' | 'false'>('null') // 批量设置的计入统计
|
||||
const batchAddTagIds = ref<number[]>([]) // 批量添加的标签
|
||||
const batchRemoveTagIds = ref<number[]>([]) // 批量移除的标签
|
||||
const batching = ref(false) // 批量设置提交状态
|
||||
|
||||
// 批量设置计入统计选项
|
||||
const batchStatsOptions = [
|
||||
{ label: '不修改', value: 'null' },
|
||||
{ label: '计入统计', value: 'true' },
|
||||
{ label: '不计入统计', value: 'false' },
|
||||
]
|
||||
const editingId = ref<number | null>(null) // 当前编辑的记录 ID(null 表示新增)
|
||||
const categories = ref<CategoryOption[]>([]) // 分类选项列表
|
||||
const allTags = ref<TagOption[]>([]) // 全部标签(用于筛选、编辑、批量)
|
||||
const revealedRemarkIds = ref<number[]>([]) // 临时展开查看原文的备注行 ID(不持久,刷新/翻页后重置)
|
||||
|
||||
// 表单数据(新增/编辑共用)
|
||||
@@ -73,6 +105,7 @@ const form = ref({
|
||||
remark: '',
|
||||
transactionTime: Date.now(),
|
||||
includeInStats: true,
|
||||
tagIds: [] as number[],
|
||||
})
|
||||
|
||||
// 类型选项
|
||||
@@ -98,6 +131,27 @@ const filterIncludeInStats = ref<string | null>(null) // 计入统计筛选:nu
|
||||
const filterRemark = ref('') // 备注关键字搜索
|
||||
const filterMinAmount = ref<number | null>(null) // 最小金额筛选
|
||||
const filterMaxAmount = ref<number | null>(null) // 最大金额筛选
|
||||
const filterTag = ref<number[]>([]) // 标签筛选(多选)
|
||||
const showMoreFilter = ref(false) // 更多筛选弹层显示状态
|
||||
|
||||
// 更多筛选中已激活的条件数量
|
||||
const moreFilterCount = computed(() => {
|
||||
let count = 0
|
||||
if (filterPaymentMethod.value.length > 0) count++
|
||||
if (filterTag.value.length > 0) count++
|
||||
if (filterIncludeInStats.value !== null) count++
|
||||
if (filterMinAmount.value !== null || filterMaxAmount.value !== null) count++
|
||||
return count
|
||||
})
|
||||
|
||||
/** 清空「更多筛选」中的条件 */
|
||||
function clearMoreFilters() {
|
||||
filterPaymentMethod.value = []
|
||||
filterTag.value = []
|
||||
filterIncludeInStats.value = null
|
||||
filterMinAmount.value = null
|
||||
filterMaxAmount.value = null
|
||||
}
|
||||
|
||||
// 分类筛选项:固定包含「未分类」,其余分类按类型筛选联动
|
||||
const categoryFilterOptions = computed(() => [
|
||||
@@ -105,6 +159,9 @@ const categoryFilterOptions = computed(() => [
|
||||
...categories.value.filter(c => filterType.value.length === 0 || filterType.value.includes(c.type)),
|
||||
])
|
||||
|
||||
// 标签筛选项
|
||||
const tagFilterOptions = computed(() => allTags.value.map((t) => ({ label: t.name, value: t.id })))
|
||||
|
||||
/** 将分类筛选拆分为分类ID与未分类标记,供查询与导出复用 */
|
||||
function buildCategoryFilter() {
|
||||
const ids = filterCategory.value.filter((v) => v !== 0)
|
||||
@@ -138,6 +195,8 @@ function loadFilterPrefs() {
|
||||
if (typeof p.filterMinAmount === 'number') filterMinAmount.value = p.filterMinAmount
|
||||
if (typeof p.filterMaxAmount === 'number') filterMaxAmount.value = p.filterMaxAmount
|
||||
if (typeof p.pageSize === 'number' && pageSizes.includes(p.pageSize)) pageSize.value = p.pageSize
|
||||
if (typeof p.sortValue === 'string' && sortOptions.some((o) => o.value === p.sortValue)) sortValue.value = p.sortValue
|
||||
if (Array.isArray(p.filterTag)) filterTag.value = p.filterTag
|
||||
} catch (e) {
|
||||
// 忽略损坏的偏好数据
|
||||
}
|
||||
@@ -156,6 +215,8 @@ function saveFilterPrefs() {
|
||||
filterMinAmount: filterMinAmount.value,
|
||||
filterMaxAmount: filterMaxAmount.value,
|
||||
pageSize: pageSize.value,
|
||||
sortValue: sortValue.value,
|
||||
filterTag: filterTag.value,
|
||||
}))
|
||||
} catch (e) {
|
||||
// 忽略写入失败
|
||||
@@ -164,7 +225,7 @@ function saveFilterPrefs() {
|
||||
|
||||
// 筛选条件变化时自动保存用户偏好
|
||||
watch(
|
||||
[filterType, filterCategory, filterPaymentMethod, filterDateRange, filterIncludeInStats, filterRemark, filterMinAmount, filterMaxAmount, pageSize],
|
||||
[filterType, filterCategory, filterPaymentMethod, filterDateRange, filterIncludeInStats, filterRemark, filterMinAmount, filterMaxAmount, pageSize, sortValue, filterTag],
|
||||
saveFilterPrefs,
|
||||
{ deep: true },
|
||||
)
|
||||
@@ -192,6 +253,14 @@ function renderCategoryLabel(option: any) {
|
||||
])
|
||||
}
|
||||
|
||||
/** 分类已选项(多选标签)渲染:图标 + 名称 */
|
||||
function renderCategoryTag({ option }: { option: any }) {
|
||||
return h('span', null, [
|
||||
option.icon ? h('span', { style: { marginRight: '4px' } }, option.icon) : null,
|
||||
option.label,
|
||||
])
|
||||
}
|
||||
|
||||
/** 渲染备注单元格内的小图标按钮 */
|
||||
function renderRemarkAction(label: string, icon: any, onClick: () => void) {
|
||||
return h(NButton, {
|
||||
@@ -234,8 +303,6 @@ function createColumns(): DataTableColumns<TransactionItem> {
|
||||
return [
|
||||
{
|
||||
title: '交易时间', key: 'transactionTime', width: 170,
|
||||
sorter: (a, b) => (a.transactionTime || '').localeCompare(b.transactionTime || ''),
|
||||
defaultSortOrder: 'descend',
|
||||
render(row) {
|
||||
const time = row.transactionTime?.replace('T', ' ').substring(0, 16) || '-'
|
||||
const created = row.createdAt?.replace('T', ' ').substring(0, 16)
|
||||
@@ -260,7 +327,6 @@ function createColumns(): DataTableColumns<TransactionItem> {
|
||||
},
|
||||
{
|
||||
title: '金额', key: 'amount', width: 120,
|
||||
sorter: (a, b) => a.amount - b.amount,
|
||||
render(row) {
|
||||
if (!row.includeInStats) {
|
||||
return h('span', { style: { color: '#999', fontWeight: 'bold' } }, `${row.amount.toFixed(2)}`)
|
||||
@@ -296,6 +362,16 @@ function createColumns(): DataTableColumns<TransactionItem> {
|
||||
return h('div', { class: 'remark-cell' }, nodes)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '标签', key: 'tags', minWidth: 140,
|
||||
render(row) {
|
||||
if (!row.tags || row.tags.length === 0) return '-'
|
||||
return h(NSpace, { size: 4 }, {
|
||||
default: () => row.tags.map((tag) =>
|
||||
h(NTag, { size: 'small', color: { color: tag.color || '#2080f0', textColor: '#fff' } }, { default: () => tag.name })),
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '计入统计', key: 'includeInStats', width: 100, align: 'center',
|
||||
render(row) {
|
||||
@@ -306,11 +382,12 @@ function createColumns(): DataTableColumns<TransactionItem> {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'actions', width: 160,
|
||||
title: '操作', key: 'actions', width: 240,
|
||||
render(row) {
|
||||
return h(NSpace, null, {
|
||||
default: () => [
|
||||
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => '编辑' }),
|
||||
h(NButton, { size: 'small', disabled: !row.rawData, onClick: () => handleViewRaw(row) }, { default: () => '原始数据' }),
|
||||
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => '删除' }),
|
||||
],
|
||||
})
|
||||
@@ -331,6 +408,16 @@ async function fetchCategories() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载标签列表 */
|
||||
async function fetchTags() {
|
||||
try {
|
||||
const res: any = await getTagList()
|
||||
allTags.value = res.data || []
|
||||
} catch (e) {
|
||||
// 静默处理
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载交易记录列表(分页),携带所有筛选条件 */
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
@@ -347,6 +434,9 @@ async function fetchData() {
|
||||
minAmount: filterMinAmount.value ?? undefined,
|
||||
maxAmount: filterMaxAmount.value ?? undefined,
|
||||
remark: filterRemark.value.trim() || undefined,
|
||||
sortField: sortField.value,
|
||||
sortOrder: sortOrder.value,
|
||||
tagIds: filterTag.value.length > 0 ? filterTag.value : undefined,
|
||||
})
|
||||
transactions.value = res.data.records
|
||||
total.value = res.data.total
|
||||
@@ -358,10 +448,30 @@ async function fetchData() {
|
||||
}
|
||||
}
|
||||
|
||||
// 排序方式变化时重置到第一页并重新查询
|
||||
watch(sortValue, () => {
|
||||
page.value = 1
|
||||
fetchData()
|
||||
})
|
||||
|
||||
/** 查看某条记录的原始账单数据(格式化为缩进 JSON) */
|
||||
function handleViewRaw(row: TransactionItem) {
|
||||
if (!row.rawData) {
|
||||
message.warning('该记录没有原始账单数据')
|
||||
return
|
||||
}
|
||||
try {
|
||||
rawDataText.value = JSON.stringify(JSON.parse(row.rawData), null, 2)
|
||||
} catch (e) {
|
||||
rawDataText.value = row.rawData
|
||||
}
|
||||
showRawModal.value = true
|
||||
}
|
||||
|
||||
/** 打开新增记录弹窗,重置表单 */
|
||||
function handleAdd() {
|
||||
editingId.value = null
|
||||
form.value = { categoryId: null, type: 'EXPENSE', amount: null, paymentMethod: '', remark: '', transactionTime: Date.now(), includeInStats: true }
|
||||
form.value = { categoryId: null, type: 'EXPENSE', amount: null, paymentMethod: '', remark: '', transactionTime: Date.now(), includeInStats: true, tagIds: [] }
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
@@ -376,6 +486,7 @@ function handleEdit(row: TransactionItem) {
|
||||
remark: row.remark || '',
|
||||
transactionTime: new Date(row.transactionTime).getTime(),
|
||||
includeInStats: row.includeInStats ?? true,
|
||||
tagIds: (row.tags || []).map((t) => t.id),
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
@@ -398,6 +509,7 @@ async function handleSubmit() {
|
||||
remark: form.value.remark || undefined,
|
||||
transactionTime: new Date(form.value.transactionTime).toISOString().slice(0, 19),
|
||||
includeInStats: form.value.includeInStats,
|
||||
tagIds: form.value.tagIds,
|
||||
}
|
||||
try {
|
||||
if (editingId.value) {
|
||||
@@ -462,6 +574,7 @@ function handleReset() {
|
||||
filterRemark.value = ''
|
||||
filterMinAmount.value = null
|
||||
filterMaxAmount.value = null
|
||||
filterTag.value = []
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
@@ -485,23 +598,39 @@ function handleBatchOpen() {
|
||||
}
|
||||
batchExpenseCategoryId.value = null
|
||||
batchIncomeCategoryId.value = null
|
||||
batchIncludeInStats.value = 'null'
|
||||
batchAddTagIds.value = []
|
||||
batchRemoveTagIds.value = []
|
||||
showBatchModal.value = true
|
||||
}
|
||||
|
||||
/** 对当前表格展示的记录按类型批量设置分类 */
|
||||
async function handleBatchSubmit() {
|
||||
if (!batchExpenseCategoryId.value && !batchIncomeCategoryId.value) {
|
||||
message.warning('请至少选择一个分类')
|
||||
const hasCategoryOrStats = !!(batchExpenseCategoryId.value || batchIncomeCategoryId.value || batchIncludeInStats.value !== 'null')
|
||||
const hasTags = batchAddTagIds.value.length > 0 || batchRemoveTagIds.value.length > 0
|
||||
if (!hasCategoryOrStats && !hasTags) {
|
||||
message.warning('请至少选择分类、计入统计或标签')
|
||||
return
|
||||
}
|
||||
batching.value = true
|
||||
try {
|
||||
const res: any = await batchUpdateCategory({
|
||||
ids: transactions.value.map((t) => t.id),
|
||||
const ids = transactions.value.map((t) => t.id)
|
||||
if (hasCategoryOrStats) {
|
||||
await batchUpdateCategory({
|
||||
ids,
|
||||
expenseCategoryId: batchExpenseCategoryId.value,
|
||||
incomeCategoryId: batchIncomeCategoryId.value,
|
||||
includeInStats: batchIncludeInStats.value === 'true' ? true : batchIncludeInStats.value === 'false' ? false : null,
|
||||
})
|
||||
message.success(`已更新 ${res.data} 条记录`)
|
||||
}
|
||||
if (hasTags) {
|
||||
await batchTags({
|
||||
ids,
|
||||
addTagIds: batchAddTagIds.value,
|
||||
removeTagIds: batchRemoveTagIds.value,
|
||||
})
|
||||
}
|
||||
message.success(`已更新 ${ids.length} 条记录`)
|
||||
showBatchModal.value = false
|
||||
await fetchData()
|
||||
} catch (e: any) {
|
||||
@@ -524,6 +653,9 @@ async function handleExport() {
|
||||
minAmount: filterMinAmount.value ?? undefined,
|
||||
maxAmount: filterMaxAmount.value ?? undefined,
|
||||
remark: filterRemark.value.trim() || undefined,
|
||||
sortField: sortField.value,
|
||||
sortOrder: sortOrder.value,
|
||||
tagIds: filterTag.value.length > 0 ? filterTag.value : undefined,
|
||||
})
|
||||
const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
@@ -544,6 +676,7 @@ async function handleExport() {
|
||||
onMounted(() => {
|
||||
loadFilterPrefs()
|
||||
fetchCategories()
|
||||
fetchTags()
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
@@ -553,15 +686,17 @@ onMounted(() => {
|
||||
<!-- 顶部:标题和新增按钮 -->
|
||||
<NSpace justify="space-between" style="margin-bottom: 16px;">
|
||||
<h2 style="margin: 0;">收支记录</h2>
|
||||
<NSpace>
|
||||
<NButton @click="handleBatchOpen">一键设置分类</NButton>
|
||||
<NSpace align="center">
|
||||
<span style="color: #999; font-size: 13px;">排序:</span>
|
||||
<NSelect v-model:value="sortValue" :options="sortOptions" style="width: 170px;" />
|
||||
<NButton @click="handleBatchOpen">一键设置</NButton>
|
||||
<NButton @click="showImportModal = true">导入账单</NButton>
|
||||
<NButton @click="handleExport">导出 Excel</NButton>
|
||||
<NButton type="primary" @click="handleAdd">新增记录</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<!-- 筛选条件区域 -->
|
||||
<!-- 筛选条件区域:常用条件常驻,其余收进「更多筛选」 -->
|
||||
<NCard :bordered="false" style="margin-bottom: 16px;">
|
||||
<NSpace align="center" :wrap="true" :size="[12, 12]">
|
||||
<NSelect
|
||||
@@ -576,6 +711,8 @@ onMounted(() => {
|
||||
<NSelect
|
||||
v-model:value="filterCategory"
|
||||
:options="categoryFilterOptions"
|
||||
:render-label="renderCategoryLabel"
|
||||
:render-tag="renderCategoryTag"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
@@ -583,45 +720,12 @@ onMounted(() => {
|
||||
placeholder="分类"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="filterPaymentMethod"
|
||||
:options="paymentOptions"
|
||||
multiple
|
||||
clearable
|
||||
:max-tag-count="2"
|
||||
placeholder="支付方式"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<NDatePicker
|
||||
v-model:value="filterDateRange"
|
||||
type="daterange"
|
||||
clearable
|
||||
style="width: 260px;"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="filterIncludeInStats"
|
||||
:options="includeInStatsOptions"
|
||||
clearable
|
||||
placeholder="计入统计"
|
||||
style="width: 130px;"
|
||||
/>
|
||||
<NInputNumber
|
||||
v-model:value="filterMinAmount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
clearable
|
||||
placeholder="最小金额"
|
||||
style="width: 130px;"
|
||||
/>
|
||||
<span style="color: #999;">~</span>
|
||||
<NInputNumber
|
||||
v-model:value="filterMaxAmount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
clearable
|
||||
placeholder="最大金额"
|
||||
style="width: 130px;"
|
||||
/>
|
||||
<NInput
|
||||
v-model:value="filterRemark"
|
||||
clearable
|
||||
@@ -629,8 +733,78 @@ onMounted(() => {
|
||||
style="width: 180px;"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<NPopover v-model:show="showMoreFilter" trigger="click" placement="bottom-start">
|
||||
<template #trigger>
|
||||
<NButton>
|
||||
更多筛选
|
||||
<NTag v-if="moreFilterCount > 0" :bordered="false" size="small" type="primary" style="margin-left: 6px;">
|
||||
{{ moreFilterCount }}
|
||||
</NTag>
|
||||
</NButton>
|
||||
</template>
|
||||
<NSpace vertical :size="12" style="width: 260px;">
|
||||
<div>
|
||||
<div class="filter-label">支付方式</div>
|
||||
<NSelect
|
||||
v-model:value="filterPaymentMethod"
|
||||
:options="paymentOptions"
|
||||
multiple
|
||||
clearable
|
||||
:max-tag-count="2"
|
||||
placeholder="全部"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="filter-label">标签</div>
|
||||
<NSelect
|
||||
v-model:value="filterTag"
|
||||
:options="tagFilterOptions"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
:max-tag-count="2"
|
||||
placeholder="全部"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="filter-label">计入统计</div>
|
||||
<NSelect
|
||||
v-model:value="filterIncludeInStats"
|
||||
:options="includeInStatsOptions"
|
||||
clearable
|
||||
placeholder="全部"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="filter-label">金额区间</div>
|
||||
<NSpace align="center" :size="8">
|
||||
<NInputNumber
|
||||
v-model:value="filterMinAmount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
clearable
|
||||
placeholder="最小"
|
||||
style="width: 110px;"
|
||||
/>
|
||||
<span style="color: #999;">~</span>
|
||||
<NInputNumber
|
||||
v-model:value="filterMaxAmount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
clearable
|
||||
placeholder="最大"
|
||||
style="width: 110px;"
|
||||
/>
|
||||
</NSpace>
|
||||
<NSpace justify="end" style="margin-top: 12px;">
|
||||
</div>
|
||||
<NSpace justify="end">
|
||||
<NButton size="small" @click="clearMoreFilters">清空</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NPopover>
|
||||
<NButton type="primary" @click="handleSearch">查询</NButton>
|
||||
<NButton @click="handleReset">重置</NButton>
|
||||
</NSpace>
|
||||
@@ -686,6 +860,16 @@ onMounted(() => {
|
||||
<NFormItem label="备注">
|
||||
<NInput v-model:value="form.remark" type="textarea" placeholder="可选备注说明" />
|
||||
</NFormItem>
|
||||
<NFormItem label="标签">
|
||||
<NSelect
|
||||
v-model:value="form.tagIds"
|
||||
:options="tagFilterOptions"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
placeholder="选择标签"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="计入统计">
|
||||
<NSwitch v-model:value="form.includeInStats" />
|
||||
<span style="color: #999; margin-left: 8px; font-size: 12px;">关闭后不纳入月度统计(如借款、转账等)</span>
|
||||
@@ -697,12 +881,17 @@ onMounted(() => {
|
||||
<!-- 账单导入弹窗 -->
|
||||
<BillImportModal v-model:show="showImportModal" @imported="fetchData" />
|
||||
|
||||
<!-- 一键设置分类弹窗(作用于当前表格展示的记录) -->
|
||||
<NModal v-model:show="showBatchModal" preset="card" title="一键设置分类" style="width: 480px;">
|
||||
<!-- 原始账单数据弹窗 -->
|
||||
<NModal v-model:show="showRawModal" preset="card" title="原始账单数据" style="width: 640px;">
|
||||
<NInput :value="rawDataText" type="textarea" readonly :autosize="{ minRows: 8, maxRows: 20 }" />
|
||||
</NModal>
|
||||
|
||||
<!-- 一键设置弹窗(作用于当前表格展示的记录) -->
|
||||
<NModal v-model:show="showBatchModal" preset="card" title="一键设置分类与统计" style="width: 480px;">
|
||||
<NForm>
|
||||
<NFormItem label="作用范围">
|
||||
<span style="color: #999; font-size: 12px;">
|
||||
将对当前表格展示的 {{ transactions.length }} 条记录按类型设置分类
|
||||
将对当前表格展示的 {{ transactions.length }} 条记录设置分类与计入统计
|
||||
</span>
|
||||
</NFormItem>
|
||||
<NFormItem label="支出分类">
|
||||
@@ -725,6 +914,15 @@ onMounted(() => {
|
||||
placeholder="不设置收入分类"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="计入统计">
|
||||
<NSelect v-model:value="batchIncludeInStats" :options="batchStatsOptions" />
|
||||
</NFormItem>
|
||||
<NFormItem label="添加标签">
|
||||
<NSelect v-model:value="batchAddTagIds" :options="tagFilterOptions" multiple clearable filterable placeholder="不添加标签" />
|
||||
</NFormItem>
|
||||
<NFormItem label="移除标签">
|
||||
<NSelect v-model:value="batchRemoveTagIds" :options="tagFilterOptions" multiple clearable filterable placeholder="不移除标签" />
|
||||
</NFormItem>
|
||||
<NButton type="primary" block :loading="batching" @click="handleBatchSubmit">确定</NButton>
|
||||
</NForm>
|
||||
</NModal>
|
||||
@@ -743,4 +941,9 @@ onMounted(() => {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.filter-label {
|
||||
margin-bottom: 4px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.example.finance.controller;
|
||||
|
||||
import com.example.finance.common.Result;
|
||||
import com.example.finance.dto.TagRequest;
|
||||
import com.example.finance.service.TagService;
|
||||
import com.example.finance.vo.TagVO;
|
||||
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.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 标签控制器
|
||||
*
|
||||
* <p>提供标签的增删改查接口,用于给交易记录打标签</p>
|
||||
*
|
||||
* @date 2026-09-17 16:26:58
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/tag")
|
||||
@Tag(name = "标签管理", description = "标签的增删改查相关接口")
|
||||
public class TagController {
|
||||
|
||||
private final TagService tagService;
|
||||
|
||||
public TagController(TagService tagService) {
|
||||
this.tagService = tagService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取标签列表
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @return 标签列表
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
@Operation(summary = "获取标签列表", description = "获取当前用户的全部标签")
|
||||
public Result<List<TagVO>> list(HttpServletRequest request) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(tagService.list(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建标签
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param tagRequest 标签请求参数
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PostMapping
|
||||
@Operation(summary = "创建标签", description = "为当前用户创建新的标签")
|
||||
public Result<Void> create(HttpServletRequest request, @RequestBody @Valid TagRequest tagRequest) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
tagService.create(userId, tagRequest);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新标签
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param id 标签ID
|
||||
* @param tagRequest 标签请求参数
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "更新标签", description = "根据标签ID更新标签信息")
|
||||
public Result<Void> update(HttpServletRequest request,
|
||||
@Parameter(description = "标签ID") @PathVariable Long id,
|
||||
@RequestBody @Valid TagRequest tagRequest) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
tagService.update(userId, id, tagRequest);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除标签
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param id 标签ID
|
||||
* @return 操作结果
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "删除标签", description = "根据标签ID删除指定标签及其交易关联")
|
||||
public Result<Void> delete(HttpServletRequest request,
|
||||
@Parameter(description = "标签ID") @PathVariable Long id) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
tagService.delete(userId, id);
|
||||
return Result.success();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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.BatchTagRequest;
|
||||
import com.example.finance.dto.TransactionQuery;
|
||||
import com.example.finance.dto.TransactionRequest;
|
||||
import com.example.finance.service.TransactionService;
|
||||
@@ -165,6 +166,21 @@ public class TransactionController {
|
||||
return Result.success(transactionService.batchUpdateCategory(userId, body));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量给交易记录添加/移除标签
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param body 批量标签请求参数
|
||||
* @return 涉及的记录数
|
||||
*/
|
||||
@PutMapping("/batch-tags")
|
||||
@Operation(summary = "批量设置标签", description = "为选中的交易记录添加或移除标签")
|
||||
public Result<Integer> batchTags(HttpServletRequest request,
|
||||
@RequestBody @Valid BatchTagRequest body) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(transactionService.batchTags(userId, body));
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出交易记录为 Excel 文件
|
||||
*
|
||||
|
||||
@@ -22,4 +22,7 @@ public class BatchCategoryRequest {
|
||||
|
||||
/** 收入默认分类ID,为空表示不更新收入记录 */
|
||||
private Long incomeCategoryId;
|
||||
|
||||
/** 是否计入统计:null-不修改,true-计入,false-不计入 */
|
||||
private Boolean includeInStats;
|
||||
}
|
||||
|
||||
@@ -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 16:27:22
|
||||
*/
|
||||
@Data
|
||||
public class BatchTagRequest {
|
||||
|
||||
/** 待操作的交易记录ID列表 */
|
||||
@NotEmpty(message = "请至少选择一条记录")
|
||||
private List<Long> ids;
|
||||
|
||||
/** 需要添加的标签ID列表,可选 */
|
||||
private List<Long> addTagIds;
|
||||
|
||||
/** 需要移除的标签ID列表,可选 */
|
||||
private List<Long> removeTagIds;
|
||||
}
|
||||
@@ -25,6 +25,9 @@ public class BillImportRequest {
|
||||
/** 收入默认分类ID,适用于所有收入类型行 */
|
||||
private Long incomeCategoryId;
|
||||
|
||||
/** 导入记录的默认标签ID列表,可选 */
|
||||
private List<Long> tagIds;
|
||||
|
||||
/** 勾选待导入的账单行列表 */
|
||||
private List<Item> items;
|
||||
|
||||
@@ -54,5 +57,8 @@ public class BillImportRequest {
|
||||
|
||||
/** 是否计入统计 */
|
||||
private Boolean includeInStats;
|
||||
|
||||
/** 原始账单行的完整数据(结构化JSON,由解析阶段返回、确认导入时回传) */
|
||||
private String rawData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.example.finance.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 标签请求DTO,用于接收创建或更新标签时提交的信息。
|
||||
*
|
||||
* @date 2026-09-17 16:26:52
|
||||
*/
|
||||
@Data
|
||||
public class TagRequest {
|
||||
|
||||
/** 标签名称,不能为空 */
|
||||
@NotBlank(message = "标签名称不能为空")
|
||||
private String name;
|
||||
|
||||
/** 标签颜色,可选 */
|
||||
private String color;
|
||||
|
||||
/** 排序序号,可选 */
|
||||
private Integer sortOrder;
|
||||
}
|
||||
@@ -46,4 +46,13 @@ public class TransactionQuery {
|
||||
|
||||
/** 备注关键字,模糊匹配 */
|
||||
private String remark;
|
||||
|
||||
/** 排序字段:transactionTime-交易时间(默认),amount-金额 */
|
||||
private String sortField;
|
||||
|
||||
/** 排序方向:asc-升序,desc-降序(默认) */
|
||||
private String sortOrder;
|
||||
|
||||
/** 标签ID筛选,支持多选 */
|
||||
private List<Long> tagIds;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 交易请求DTO,用于接收创建或更新交易记录时提交的信息
|
||||
@@ -39,4 +40,7 @@ public class TransactionRequest {
|
||||
|
||||
/** 是否计入统计,默认 true(借款、转账等可设为 false) */
|
||||
private Boolean includeInStats;
|
||||
|
||||
/** 标签ID列表,可选;更新时为空表示清空标签 */
|
||||
private List<Long> tagIds;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.example.finance.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 标签实体类,对应数据库表 t_tag,用于记录用户自定义标签。
|
||||
*
|
||||
* @date 2026-09-17 16:26:50
|
||||
*/
|
||||
@Data
|
||||
@TableName("t_tag")
|
||||
public class Tag {
|
||||
|
||||
/** 标签ID,主键自增 */
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 所属用户ID */
|
||||
private Long userId;
|
||||
|
||||
/** 标签名称 */
|
||||
private String name;
|
||||
|
||||
/** 标签颜色,用于前端展示 */
|
||||
private String color;
|
||||
|
||||
/** 排序序号 */
|
||||
private Integer sortOrder;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -49,6 +49,9 @@ public class Transaction {
|
||||
/** 是否计入统计:true-计入,false-不计入(如借款、账户间转账等) */
|
||||
private Boolean includeInStats;
|
||||
|
||||
/** 账单导入的原始完整数据(结构化JSON),手动记录为空 */
|
||||
private String rawData;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.example.finance.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 交易标签关联实体类,对应数据库表 t_transaction_tag,维护交易与标签的多对多关系。
|
||||
*
|
||||
* @date 2026-09-17 16:26:50
|
||||
*/
|
||||
@Data
|
||||
@TableName("t_transaction_tag")
|
||||
public class TransactionTag {
|
||||
|
||||
/** 关联ID,主键自增 */
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 交易记录ID */
|
||||
private Long transactionId;
|
||||
|
||||
/** 标签ID */
|
||||
private Long tagId;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -3,14 +3,17 @@ package com.example.finance.enums;
|
||||
/**
|
||||
* 账单类型枚举,标识支持的第三方账单来源。
|
||||
*
|
||||
* <p>当前仅支持支付宝,后续可扩展微信、银行等账单类型。</p>
|
||||
* <p>当前支持支付宝与微信支付账单,后续可扩展银行等账单类型。</p>
|
||||
*
|
||||
* @date 2026-09-17 09:49:43
|
||||
*/
|
||||
public enum BillType {
|
||||
|
||||
/** 支付宝账单 */
|
||||
ALIPAY("支付宝");
|
||||
ALIPAY("支付宝"),
|
||||
|
||||
/** 微信支付账单 */
|
||||
WECHAT("微信");
|
||||
|
||||
/** 账单类型的中文名称 */
|
||||
private final String label;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.example.finance.importer;
|
||||
|
||||
import com.example.finance.enums.BillType;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.commons.csv.CSVFormat;
|
||||
import org.apache.commons.csv.CSVParser;
|
||||
import org.apache.commons.csv.CSVRecord;
|
||||
@@ -18,7 +20,9 @@ import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 支付宝账单解析器。
|
||||
@@ -48,6 +52,13 @@ public class AlipayBillParser implements BillParser {
|
||||
/** CSV 解析格式:逗号分隔并去除字段首尾空白 */
|
||||
private static final CSVFormat CSV_FORMAT = CSVFormat.DEFAULT.builder().setTrim(true).build();
|
||||
|
||||
/** JSON 序列化器,用于生成原始完整数据 */
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public AlipayBillParser(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BillType getBillType() {
|
||||
return BillType.ALIPAY;
|
||||
@@ -125,6 +136,7 @@ public class AlipayBillParser implements BillParser {
|
||||
private ParsedBillRow toRow(int rowNo, List<String> header, List<String> values) {
|
||||
ParsedBillRow row = new ParsedBillRow();
|
||||
row.setRowNo(rowNo);
|
||||
row.setRawData(buildRawData(header, values));
|
||||
row.setExternalNo(get(header, values, COL_TRADE_NO));
|
||||
row.setCounterparty(get(header, values, COL_COUNTERPARTY));
|
||||
row.setProduct(get(header, values, COL_PRODUCT));
|
||||
@@ -180,6 +192,31 @@ public class AlipayBillParser implements BillParser {
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将整行数据按表头顺序组装为结构化 JSON,保留全部列(含系统未使用的字段)
|
||||
*
|
||||
* @param header 表头字段列表
|
||||
* @param values 数据字段列表
|
||||
* @return JSON 字符串,序列化失败时返回 null
|
||||
*/
|
||||
private String buildRawData(List<String> header, List<String> values) {
|
||||
Map<String, String> map = new LinkedHashMap<>();
|
||||
for (int i = 0; i < header.size(); i++) {
|
||||
String key = header.get(i);
|
||||
if (key == null || key.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
String value = i < values.size() ? values.get(i) : null;
|
||||
map.put(key, value == null ? "" : value.trim());
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(map);
|
||||
} catch (JsonProcessingException e) {
|
||||
// 序列化失败不影响正常导入,原始数据置空
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 推断交易类型
|
||||
*
|
||||
|
||||
@@ -54,6 +54,9 @@ public class ParsedBillRow {
|
||||
/** 是否与系统中已有记录重复(同一用户下交易号已存在) */
|
||||
private boolean duplicate;
|
||||
|
||||
/** 原始账单行的完整数据(结构化JSON,保留全部列) */
|
||||
private String rawData;
|
||||
|
||||
/** 解析失败原因,正常时为 null */
|
||||
private String error;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
package com.example.finance.importer;
|
||||
|
||||
import com.example.finance.enums.BillType;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.CellType;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 微信支付账单解析器。
|
||||
*
|
||||
* <p>解析微信「支付账单明细」导出的 xlsx 文件。文件前若干行为昵称、起止时间、统计与注释,
|
||||
* 之后为表头与数据行。交易时间以 Excel 日期序列号存储,金额为数值,其余列均为文本。</p>
|
||||
*
|
||||
* @date 2026-09-17 13:39:08
|
||||
*/
|
||||
@Component
|
||||
public class WechatBillParser implements BillParser {
|
||||
|
||||
/** 表头首列名称,用于定位表头行 */
|
||||
private static final String HEADER_FIRST_COLUMN = "交易时间";
|
||||
|
||||
/** 微信账单的列数 */
|
||||
private static final int COLUMN_COUNT = 11;
|
||||
|
||||
/** Excel 日期序列号基准(1900 日期系统,已规避 1900 闰年问题) */
|
||||
private static final LocalDateTime EXCEL_EPOCH = LocalDateTime.of(1899, 12, 30, 0, 0);
|
||||
|
||||
/** 微信账单中表示「无内容」的占位符 */
|
||||
private static final String PLACEHOLDER = "/";
|
||||
|
||||
/** 微信账单中的时间格式 */
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
/** 兼容部分版本以文本形式导出的时间格式 */
|
||||
private static final List<DateTimeFormatter> TEXT_TIME_FORMATTERS = List.of(
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
|
||||
DateTimeFormatter.ofPattern("yyyy/M/d H:mm:ss"),
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
|
||||
|
||||
/** JSON 序列化器,用于生成原始完整数据 */
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public WechatBillParser(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BillType getBillType() {
|
||||
return BillType.WECHAT;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 定位表头后逐行读取数据,跳过缺少有效交易时间的空行。
|
||||
*/
|
||||
@Override
|
||||
public List<ParsedBillRow> parse(byte[] content) throws IOException {
|
||||
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(content))) {
|
||||
Sheet sheet = workbook.getSheetAt(0);
|
||||
int headerIndex = locateHeader(sheet);
|
||||
if (headerIndex < 0) {
|
||||
throw new IOException("未找到微信账单表头,请确认文件是否为微信导出的支付账单明细");
|
||||
}
|
||||
List<String> header = readHeader(sheet.getRow(headerIndex));
|
||||
|
||||
List<ParsedBillRow> rows = new ArrayList<>();
|
||||
int rowNo = 0;
|
||||
for (int i = headerIndex + 1; i <= sheet.getLastRowNum(); i++) {
|
||||
Row row = sheet.getRow(i);
|
||||
LocalDateTime time = readTime(row);
|
||||
// 空行(含被裁剪文件中的中间空行)直接跳过
|
||||
if (time == null) {
|
||||
continue;
|
||||
}
|
||||
rowNo++;
|
||||
rows.add(toRow(rowNo, header, row, time));
|
||||
}
|
||||
return rows;
|
||||
} catch (IOException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new IOException("微信账单解析失败:" + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 定位表头行:首个单元格以「交易时间」开头的行
|
||||
*
|
||||
* @param sheet 工作表
|
||||
* @return 表头行索引,未找到时返回 -1
|
||||
*/
|
||||
private int locateHeader(Sheet sheet) {
|
||||
for (int i = sheet.getFirstRowNum(); i <= sheet.getLastRowNum(); i++) {
|
||||
Row row = sheet.getRow(i);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
String first = readString(row.getCell(0));
|
||||
if (first != null && first.startsWith(HEADER_FIRST_COLUMN)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取表头行的各列名称
|
||||
*
|
||||
* @param row 表头行
|
||||
* @return 列名列表
|
||||
*/
|
||||
private List<String> readHeader(Row row) {
|
||||
List<String> header = new ArrayList<>(COLUMN_COUNT);
|
||||
for (int c = 0; c < COLUMN_COUNT; c++) {
|
||||
String value = readString(row.getCell(c));
|
||||
header.add(value == null ? "" : value);
|
||||
}
|
||||
return header;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单行数据映射为账单行对象,并推断类型、是否计入统计及默认勾选状态
|
||||
*
|
||||
* @param rowNo 行号
|
||||
* @param header 表头字段列表
|
||||
* @param row 数据行
|
||||
* @param time 交易时间
|
||||
* @return 解析后的账单行
|
||||
*/
|
||||
private ParsedBillRow toRow(int rowNo, List<String> header, Row row, LocalDateTime time) {
|
||||
ParsedBillRow parsed = new ParsedBillRow();
|
||||
parsed.setRowNo(rowNo);
|
||||
parsed.setTransactionTime(time);
|
||||
parsed.setRawData(buildRawData(header, row, time));
|
||||
|
||||
parsed.setExternalNo(readString(row.getCell(8)));
|
||||
parsed.setCounterparty(cleanText(readString(row.getCell(2))));
|
||||
parsed.setProduct(cleanText(readString(row.getCell(3))));
|
||||
parsed.setTradeStatus(cleanText(readString(row.getCell(7))));
|
||||
|
||||
String direction = cleanText(readString(row.getCell(4)));
|
||||
parsed.setDirection(direction);
|
||||
|
||||
BigDecimal amount = readAmount(row.getCell(5));
|
||||
parsed.setAmount(amount);
|
||||
|
||||
// 无收/支方向(如充值、提现、还款等中性交易)默认按支出处理且不计入统计
|
||||
boolean neutral = direction == null;
|
||||
parsed.setType(resolveType(direction));
|
||||
parsed.setIncludeInStats(!neutral);
|
||||
|
||||
boolean importable = StringUtils.hasText(parsed.getExternalNo())
|
||||
&& amount != null
|
||||
&& amount.compareTo(BigDecimal.ZERO) > 0;
|
||||
parsed.setImportable(importable);
|
||||
parsed.setSelected(importable && !neutral);
|
||||
|
||||
if (!StringUtils.hasText(parsed.getExternalNo())) {
|
||||
parsed.setError("缺少交易单号");
|
||||
} else if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
parsed.setError("金额格式无法解析");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 推断交易类型
|
||||
*
|
||||
* @param direction 收/支标识
|
||||
* @return INCOME 或 EXPENSE
|
||||
*/
|
||||
private String resolveType(String direction) {
|
||||
if ("收入".equals(direction)) {
|
||||
return "INCOME";
|
||||
}
|
||||
return "EXPENSE";
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取交易时间:第一列为 Excel 日期序列号,转换为本地日期时间
|
||||
*
|
||||
* @param row 数据行
|
||||
* @return 交易时间,非数值或无效时返回 null
|
||||
*/
|
||||
private LocalDateTime readTime(Row row) {
|
||||
if (row == null) {
|
||||
return null;
|
||||
}
|
||||
Cell cell = row.getCell(0);
|
||||
if (cell == null) {
|
||||
return null;
|
||||
}
|
||||
if (cell.getCellType() == CellType.NUMERIC) {
|
||||
double serial = cell.getNumericCellValue();
|
||||
if (serial <= 0) {
|
||||
return null;
|
||||
}
|
||||
long days = (long) Math.floor(serial);
|
||||
long seconds = Math.round((serial - days) * 86400.0);
|
||||
return EXCEL_EPOCH.plusDays(days).plusSeconds(seconds);
|
||||
}
|
||||
// 兼容以文本形式存储的时间
|
||||
String text = readString(cell);
|
||||
if (!StringUtils.hasText(text)) {
|
||||
return null;
|
||||
}
|
||||
for (DateTimeFormatter formatter : TEXT_TIME_FORMATTERS) {
|
||||
try {
|
||||
return LocalDateTime.parse(text, formatter);
|
||||
} catch (DateTimeParseException ignored) {
|
||||
// 尝试下一种格式
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取金额:优先按数值解析,兼容文本形式
|
||||
*
|
||||
* @param cell 金额单元格
|
||||
* @return 金额,无法解析时返回 null
|
||||
*/
|
||||
private BigDecimal readAmount(Cell cell) {
|
||||
if (cell == null) {
|
||||
return null;
|
||||
}
|
||||
if (cell.getCellType() == CellType.NUMERIC) {
|
||||
return BigDecimal.valueOf(cell.getNumericCellValue());
|
||||
}
|
||||
String text = readString(cell);
|
||||
if (!StringUtils.hasText(text)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(text.replace(",", ""));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取单元格的文本值
|
||||
*
|
||||
* @param cell 单元格
|
||||
* @return 文本值,空单元格返回 null
|
||||
*/
|
||||
private String readString(Cell cell) {
|
||||
if (cell == null) {
|
||||
return null;
|
||||
}
|
||||
CellType type = cell.getCellType();
|
||||
if (type == CellType.STRING) {
|
||||
return cell.getStringCellValue().trim();
|
||||
}
|
||||
if (type == CellType.NUMERIC) {
|
||||
return BigDecimal.valueOf(cell.getNumericCellValue()).stripTrailingZeros().toPlainString();
|
||||
}
|
||||
if (type == CellType.BOOLEAN) {
|
||||
return String.valueOf(cell.getBooleanCellValue());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将整行数据按表头顺序组装为结构化 JSON,保留全部列(含系统未使用的字段)
|
||||
*
|
||||
* @param header 表头字段列表
|
||||
* @param row 数据行
|
||||
* @param time 交易时间,第一列以可读时间替代原始序列号
|
||||
* @return JSON 字符串,序列化失败时返回 null
|
||||
*/
|
||||
private String buildRawData(List<String> header, Row row, LocalDateTime time) {
|
||||
Map<String, String> map = new LinkedHashMap<>();
|
||||
for (int c = 0; c < header.size(); c++) {
|
||||
String key = header.get(c);
|
||||
if (!StringUtils.hasText(key)) {
|
||||
continue;
|
||||
}
|
||||
String value = c == 0 ? time.format(TIME_FORMATTER) : readString(row.getCell(c));
|
||||
map.put(key, value == null ? "" : value);
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(map);
|
||||
} catch (JsonProcessingException e) {
|
||||
// 序列化失败不影响正常导入,原始数据置空
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗文本:空白或占位符「/」视为无内容
|
||||
*
|
||||
* @param value 原始文本
|
||||
* @return 清洗后的文本,无内容时返回 null
|
||||
*/
|
||||
private String cleanText(String value) {
|
||||
if (!StringUtils.hasText(value) || PLACEHOLDER.equals(value)) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.example.finance.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.example.finance.entity.Tag;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 标签Mapper接口,继承MyBatis-Plus的BaseMapper,提供标签表的基础CRUD操作。
|
||||
*
|
||||
* @date 2026-09-17 16:26:51
|
||||
*/
|
||||
@Mapper
|
||||
public interface TagMapper extends BaseMapper<Tag> {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.example.finance.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.example.finance.entity.TransactionTag;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 交易标签关联Mapper接口,继承MyBatis-Plus的BaseMapper,提供关联表的基础CRUD操作。
|
||||
*
|
||||
* @date 2026-09-17 16:26:51
|
||||
*/
|
||||
@Mapper
|
||||
public interface TransactionTagMapper extends BaseMapper<TransactionTag> {
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.example.finance.service;
|
||||
|
||||
import com.example.finance.dto.TagRequest;
|
||||
import com.example.finance.vo.TagVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 标签服务接口,提供标签的增删改查操作。
|
||||
*
|
||||
* @date 2026-09-17 16:26:53
|
||||
*/
|
||||
public interface TagService {
|
||||
|
||||
/**
|
||||
* 获取用户的所有标签列表
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 标签列表
|
||||
*/
|
||||
List<TagVO> list(Long userId);
|
||||
|
||||
/**
|
||||
* 创建新标签
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param request 标签请求参数(名称、颜色等)
|
||||
*/
|
||||
void create(Long userId, TagRequest request);
|
||||
|
||||
/**
|
||||
* 更新标签信息
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param id 标签ID
|
||||
* @param request 标签请求参数
|
||||
*/
|
||||
void update(Long userId, Long id, TagRequest request);
|
||||
|
||||
/**
|
||||
* 删除标签,同时清理交易与标签的关联
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param id 标签ID
|
||||
*/
|
||||
void delete(Long userId, Long id);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.example.finance.service;
|
||||
|
||||
import com.example.finance.common.PageResult;
|
||||
import com.example.finance.dto.BatchCategoryRequest;
|
||||
import com.example.finance.dto.BatchTagRequest;
|
||||
import com.example.finance.dto.TransactionQuery;
|
||||
import com.example.finance.dto.TransactionRequest;
|
||||
import com.example.finance.vo.TransactionExportVO;
|
||||
@@ -83,4 +84,13 @@ public interface TransactionService {
|
||||
* @return 实际更新的记录数
|
||||
*/
|
||||
int batchUpdateCategory(Long userId, BatchCategoryRequest request);
|
||||
|
||||
/**
|
||||
* 批量给交易记录添加/移除标签
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param request 批量标签请求(记录ID列表、待添加/移除的标签)
|
||||
* @return 涉及的记录数
|
||||
*/
|
||||
int batchTags(Long userId, BatchTagRequest request);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,16 @@ 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.Tag;
|
||||
import com.example.finance.entity.Transaction;
|
||||
import com.example.finance.entity.TransactionTag;
|
||||
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.TagMapper;
|
||||
import com.example.finance.mapper.TransactionMapper;
|
||||
import com.example.finance.mapper.TransactionTagMapper;
|
||||
import com.example.finance.service.BillImportService;
|
||||
import com.example.finance.vo.BillImportResultVO;
|
||||
import com.example.finance.vo.BillParseResultVO;
|
||||
@@ -41,16 +45,22 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
private final Map<BillType, BillParser> parserMap = new EnumMap<>(BillType.class);
|
||||
private final TransactionMapper transactionMapper;
|
||||
private final CategoryMapper categoryMapper;
|
||||
private final TagMapper tagMapper;
|
||||
private final TransactionTagMapper transactionTagMapper;
|
||||
|
||||
public BillImportServiceImpl(List<BillParser> parsers,
|
||||
TransactionMapper transactionMapper,
|
||||
CategoryMapper categoryMapper) {
|
||||
CategoryMapper categoryMapper,
|
||||
TagMapper tagMapper,
|
||||
TransactionTagMapper transactionTagMapper) {
|
||||
// 按账单类型注册解析器,便于扩展新账单来源
|
||||
for (BillParser parser : parsers) {
|
||||
parserMap.put(parser.getBillType(), parser);
|
||||
}
|
||||
this.transactionMapper = transactionMapper;
|
||||
this.categoryMapper = categoryMapper;
|
||||
this.tagMapper = tagMapper;
|
||||
this.transactionTagMapper = transactionTagMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,6 +117,13 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
Category incomeCategory = request.getIncomeCategoryId() == null ? null
|
||||
: requireCategory(userId, request.getIncomeCategoryId(), "INCOME");
|
||||
|
||||
// 默认标签:仅保留属于当前用户的标签
|
||||
List<Long> tagIds = request.getTagIds() == null ? List.of()
|
||||
: tagMapper.selectBatchIds(request.getTagIds()).stream()
|
||||
.filter(tag -> tag.getUserId().equals(userId))
|
||||
.map(Tag::getId)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 已存在交易号 + 本批次已插入交易号,共同用于去重
|
||||
Set<String> seen = findExistingExternalNos(userId, items.stream()
|
||||
.map(BillImportRequest.Item::getExternalNo)
|
||||
@@ -152,12 +169,20 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
transaction.setCategoryId(category != null ? category.getId() : null);
|
||||
transaction.setType(item.getType());
|
||||
transaction.setAmount(item.getAmount());
|
||||
transaction.setPaymentMethod("ALIPAY");
|
||||
transaction.setPaymentMethod(resolvePaymentMethod(request.getBillType()));
|
||||
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());
|
||||
transaction.setRawData(item.getRawData());
|
||||
transactionMapper.insert(transaction);
|
||||
// 关联默认标签
|
||||
for (Long tagId : tagIds) {
|
||||
TransactionTag relation = new TransactionTag();
|
||||
relation.setTransactionId(transaction.getId());
|
||||
relation.setTagId(tagId);
|
||||
transactionTagMapper.insert(relation);
|
||||
}
|
||||
|
||||
if (StringUtils.hasText(item.getExternalNo())) {
|
||||
seen.add(item.getExternalNo());
|
||||
@@ -224,6 +249,20 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
return category;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据账单类型解析对应的支付方式编码
|
||||
*
|
||||
* @param billType 账单类型,如 ALIPAY、WECHAT
|
||||
* @return 支付方式编码,无法识别时返回 null
|
||||
*/
|
||||
private String resolvePaymentMethod(String billType) {
|
||||
try {
|
||||
return BillType.valueOf(billType).name();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据交易对方与商品名称构造备注
|
||||
*
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
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.TagRequest;
|
||||
import com.example.finance.entity.Tag;
|
||||
import com.example.finance.entity.TransactionTag;
|
||||
import com.example.finance.mapper.TagMapper;
|
||||
import com.example.finance.mapper.TransactionTagMapper;
|
||||
import com.example.finance.service.TagService;
|
||||
import com.example.finance.vo.TagVO;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 标签服务实现类,处理标签的增删改查业务逻辑。
|
||||
*
|
||||
* @date 2026-09-17 16:26:56
|
||||
*/
|
||||
@Service
|
||||
public class TagServiceImpl implements TagService {
|
||||
|
||||
private final TagMapper tagMapper;
|
||||
private final TransactionTagMapper transactionTagMapper;
|
||||
|
||||
public TagServiceImpl(TagMapper tagMapper, TransactionTagMapper transactionTagMapper) {
|
||||
this.tagMapper = tagMapper;
|
||||
this.transactionTagMapper = transactionTagMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 查询用户所有标签,按排序字段和ID升序排列。
|
||||
*/
|
||||
@Override
|
||||
public List<TagVO> list(Long userId) {
|
||||
List<Tag> tags = tagMapper.selectList(new LambdaQueryWrapper<Tag>()
|
||||
.eq(Tag::getUserId, userId)
|
||||
.orderByAsc(Tag::getSortOrder)
|
||||
.orderByAsc(Tag::getId));
|
||||
return tags.stream().map(this::toVO).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 创建标签前校验同一用户下标签名唯一。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void create(Long userId, TagRequest request) {
|
||||
Long count = tagMapper.selectCount(new LambdaQueryWrapper<Tag>()
|
||||
.eq(Tag::getUserId, userId)
|
||||
.eq(Tag::getName, request.getName()));
|
||||
if (count != null && count > 0) {
|
||||
throw new BusinessException("标签名称已存在");
|
||||
}
|
||||
Tag tag = new Tag();
|
||||
tag.setUserId(userId);
|
||||
tag.setName(request.getName());
|
||||
tag.setColor(request.getColor());
|
||||
// 排序值未提供时默认为0
|
||||
tag.setSortOrder(request.getSortOrder() != null ? request.getSortOrder() : 0);
|
||||
tagMapper.insert(tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 更新标签前校验归属当前用户,并校验改名后不与其它标签重名。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void update(Long userId, Long id, TagRequest request) {
|
||||
Tag tag = tagMapper.selectById(id);
|
||||
if (tag == null || !tag.getUserId().equals(userId)) {
|
||||
throw new BusinessException("标签不存在");
|
||||
}
|
||||
Long count = tagMapper.selectCount(new LambdaQueryWrapper<Tag>()
|
||||
.eq(Tag::getUserId, userId)
|
||||
.eq(Tag::getName, request.getName())
|
||||
.ne(Tag::getId, id));
|
||||
if (count != null && count > 0) {
|
||||
throw new BusinessException("标签名称已存在");
|
||||
}
|
||||
tag.setName(request.getName());
|
||||
tag.setColor(request.getColor());
|
||||
if (request.getSortOrder() != null) {
|
||||
tag.setSortOrder(request.getSortOrder());
|
||||
}
|
||||
tagMapper.updateById(tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 删除标签前先清理其与交易记录的关联,避免残留脏数据。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void delete(Long userId, Long id) {
|
||||
Tag tag = tagMapper.selectById(id);
|
||||
if (tag == null || !tag.getUserId().equals(userId)) {
|
||||
throw new BusinessException("标签不存在");
|
||||
}
|
||||
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
|
||||
.eq(TransactionTag::getTagId, id));
|
||||
tagMapper.deleteById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将标签实体转换为视图对象
|
||||
*
|
||||
* @param tag 标签实体
|
||||
* @return 标签视图对象
|
||||
*/
|
||||
private TagVO toVO(Tag tag) {
|
||||
TagVO vo = new TagVO();
|
||||
vo.setId(tag.getId());
|
||||
vo.setName(tag.getName());
|
||||
vo.setColor(tag.getColor());
|
||||
vo.setSortOrder(tag.getSortOrder());
|
||||
vo.setCreatedAt(tag.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,19 @@ 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.BatchTagRequest;
|
||||
import com.example.finance.dto.TransactionQuery;
|
||||
import com.example.finance.dto.TransactionRequest;
|
||||
import com.example.finance.entity.Category;
|
||||
import com.example.finance.entity.Tag;
|
||||
import com.example.finance.entity.Transaction;
|
||||
import com.example.finance.entity.TransactionTag;
|
||||
import com.example.finance.mapper.CategoryMapper;
|
||||
import com.example.finance.mapper.TagMapper;
|
||||
import com.example.finance.mapper.TransactionMapper;
|
||||
import com.example.finance.mapper.TransactionTagMapper;
|
||||
import com.example.finance.service.TransactionService;
|
||||
import com.example.finance.vo.TagVO;
|
||||
import com.example.finance.vo.TransactionExportVO;
|
||||
import com.example.finance.vo.TransactionVO;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -21,8 +27,11 @@ import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -36,10 +45,17 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
|
||||
private final TransactionMapper transactionMapper;
|
||||
private final CategoryMapper categoryMapper;
|
||||
private final TagMapper tagMapper;
|
||||
private final TransactionTagMapper transactionTagMapper;
|
||||
|
||||
public TransactionServiceImpl(TransactionMapper transactionMapper, CategoryMapper categoryMapper) {
|
||||
public TransactionServiceImpl(TransactionMapper transactionMapper,
|
||||
CategoryMapper categoryMapper,
|
||||
TagMapper tagMapper,
|
||||
TransactionTagMapper transactionTagMapper) {
|
||||
this.transactionMapper = transactionMapper;
|
||||
this.categoryMapper = categoryMapper;
|
||||
this.tagMapper = tagMapper;
|
||||
this.transactionTagMapper = transactionTagMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,9 +113,14 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
if (StringUtils.hasText(query.getRemark())) {
|
||||
wrapper.like(Transaction::getRemark, query.getRemark());
|
||||
}
|
||||
// 按标签筛选:命中任一选中标签的记录
|
||||
if (query.getTagIds() != null && !query.getTagIds().isEmpty()) {
|
||||
wrapper.inSql(Transaction::getId, "SELECT transaction_id FROM t_transaction_tag WHERE tag_id IN ("
|
||||
+ query.getTagIds().stream().map(String::valueOf).collect(Collectors.joining(",")) + ")");
|
||||
}
|
||||
|
||||
// 按交易时间降序排列
|
||||
wrapper.orderByDesc(Transaction::getTransactionTime);
|
||||
// 应用排序:默认按交易时间降序,支持按交易时间/金额排序
|
||||
applySort(wrapper, query);
|
||||
|
||||
Page<Transaction> page = new Page<>(query.getPage(), query.getSize());
|
||||
Page<Transaction> result = transactionMapper.selectPage(page, wrapper);
|
||||
@@ -121,6 +142,8 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
List<TransactionVO> voList = result.getRecords().stream()
|
||||
.map(t -> toVO(t, finalCategoryMap.get(t.getCategoryId())))
|
||||
.collect(Collectors.toList());
|
||||
// 批量填充标签
|
||||
fillTags(voList);
|
||||
|
||||
return new PageResult<>(voList, result.getTotal(), result.getCurrent(), result.getSize());
|
||||
}
|
||||
@@ -149,6 +172,8 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
transaction.setTransactionTime(request.getTransactionTime());
|
||||
transaction.setIncludeInStats(request.getIncludeInStats() == null ? Boolean.TRUE : request.getIncludeInStats());
|
||||
transactionMapper.insert(transaction);
|
||||
// 同步标签关联
|
||||
saveTransactionTags(userId, transaction.getId(), request.getTagIds());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,6 +204,8 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
transaction.setTransactionTime(request.getTransactionTime());
|
||||
transaction.setIncludeInStats(request.getIncludeInStats() == null ? Boolean.TRUE : request.getIncludeInStats());
|
||||
transactionMapper.updateById(transaction);
|
||||
// 全量替换标签关联
|
||||
saveTransactionTags(userId, id, request.getTagIds());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,6 +220,9 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
if (transaction == null || !transaction.getUserId().equals(userId)) {
|
||||
throw new BusinessException("记录不存在");
|
||||
}
|
||||
// 先清理标签关联,再删除记录
|
||||
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
|
||||
.eq(TransactionTag::getTransactionId, id));
|
||||
transactionMapper.deleteById(id);
|
||||
}
|
||||
|
||||
@@ -244,13 +274,14 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
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("请至少选择一个分类");
|
||||
Boolean includeInStats = request.getIncludeInStats();
|
||||
if (expenseCategory == null && incomeCategory == null && includeInStats == null) {
|
||||
throw new BusinessException("请至少选择分类或计入统计");
|
||||
}
|
||||
|
||||
// 查询待更新记录,按交易类型拆分ID,避免类型不匹配的更新
|
||||
@@ -267,22 +298,197 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
.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));
|
||||
}
|
||||
updated += batchUpdate(userId, expenseIds, expenseCategory, includeInStats);
|
||||
updated += batchUpdate(userId, incomeIds, incomeCategory, includeInStats);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对指定记录批量写入分类与计入统计标记
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param ids 待更新记录ID列表
|
||||
* @param category 目标分类,为 null 时不修改分类
|
||||
* @param includeInStats 计入统计标记,为 null 时不修改
|
||||
* @return 实际更新的记录数
|
||||
*/
|
||||
private int batchUpdate(Long userId, List<Long> ids, Category category, Boolean includeInStats) {
|
||||
if (ids.isEmpty() || (category == null && includeInStats == null)) {
|
||||
return 0;
|
||||
}
|
||||
LambdaUpdateWrapper<Transaction> wrapper = new LambdaUpdateWrapper<Transaction>()
|
||||
.eq(Transaction::getUserId, userId)
|
||||
.in(Transaction::getId, ids);
|
||||
if (category != null) {
|
||||
wrapper.set(Transaction::getCategoryId, category.getId());
|
||||
}
|
||||
if (includeInStats != null) {
|
||||
wrapper.set(Transaction::getIncludeInStats, includeInStats);
|
||||
}
|
||||
return transactionMapper.update(null, wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 仅对属于当前用户的记录生效;先移除指定标签,再补充尚未存在的标签。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public int batchTags(Long userId, BatchTagRequest request) {
|
||||
List<Long> ids = request.getIds();
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
throw new BusinessException("请至少选择一条记录");
|
||||
}
|
||||
List<Long> addTagIds = validTagIds(userId, request.getAddTagIds());
|
||||
List<Long> removeTagIds = validTagIds(userId, request.getRemoveTagIds());
|
||||
if (addTagIds.isEmpty() && removeTagIds.isEmpty()) {
|
||||
throw new BusinessException("请至少选择要添加或移除的标签");
|
||||
}
|
||||
|
||||
// 仅处理属于当前用户的记录
|
||||
List<Long> validIds = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
|
||||
.select(Transaction::getId)
|
||||
.eq(Transaction::getUserId, userId)
|
||||
.in(Transaction::getId, ids))
|
||||
.stream().map(Transaction::getId).collect(Collectors.toList());
|
||||
if (validIds.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 移除标签关联
|
||||
if (!removeTagIds.isEmpty()) {
|
||||
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
|
||||
.in(TransactionTag::getTransactionId, validIds)
|
||||
.in(TransactionTag::getTagId, removeTagIds));
|
||||
}
|
||||
// 添加标签关联,跳过已存在的组合
|
||||
if (!addTagIds.isEmpty()) {
|
||||
List<TransactionTag> existing = transactionTagMapper.selectList(new LambdaQueryWrapper<TransactionTag>()
|
||||
.in(TransactionTag::getTransactionId, validIds)
|
||||
.in(TransactionTag::getTagId, addTagIds));
|
||||
Set<String> existingKeys = existing.stream()
|
||||
.map(rel -> rel.getTransactionId() + ":" + rel.getTagId())
|
||||
.collect(Collectors.toSet());
|
||||
for (Long transactionId : validIds) {
|
||||
for (Long tagId : addTagIds) {
|
||||
if (existingKeys.contains(transactionId + ":" + tagId)) {
|
||||
continue;
|
||||
}
|
||||
TransactionTag relation = new TransactionTag();
|
||||
relation.setTransactionId(transactionId);
|
||||
relation.setTagId(tagId);
|
||||
transactionTagMapper.insert(relation);
|
||||
}
|
||||
}
|
||||
}
|
||||
return validIds.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤出属于当前用户的标签ID
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param tagIds 待校验的标签ID列表
|
||||
* @return 有效的标签ID列表
|
||||
*/
|
||||
private List<Long> validTagIds(Long userId, List<Long> tagIds) {
|
||||
if (tagIds == null || tagIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return tagMapper.selectBatchIds(tagIds).stream()
|
||||
.filter(tag -> tag.getUserId().equals(userId))
|
||||
.map(Tag::getId)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量替换某条交易记录的标签关联
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param transactionId 交易记录ID
|
||||
* @param tagIds 目标标签ID列表,为空表示清空
|
||||
*/
|
||||
private void saveTransactionTags(Long userId, Long transactionId, List<Long> tagIds) {
|
||||
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
|
||||
.eq(TransactionTag::getTransactionId, transactionId));
|
||||
for (Long tagId : validTagIds(userId, tagIds)) {
|
||||
TransactionTag relation = new TransactionTag();
|
||||
relation.setTransactionId(transactionId);
|
||||
relation.setTagId(tagId);
|
||||
transactionTagMapper.insert(relation);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量填充交易视图对象的标签列表
|
||||
*
|
||||
* @param voList 交易视图对象列表
|
||||
*/
|
||||
private void fillTags(List<TransactionVO> voList) {
|
||||
if (voList.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<Long> transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList());
|
||||
List<TransactionTag> relations = transactionTagMapper.selectList(new LambdaQueryWrapper<TransactionTag>()
|
||||
.in(TransactionTag::getTransactionId, transactionIds));
|
||||
if (relations.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<Long> tagIds = relations.stream().map(TransactionTag::getTagId).distinct().collect(Collectors.toList());
|
||||
Map<Long, Tag> tagMap = tagMapper.selectBatchIds(tagIds).stream()
|
||||
.collect(Collectors.toMap(Tag::getId, tag -> tag));
|
||||
Map<Long, List<TagVO>> tagsByTransaction = new HashMap<>();
|
||||
for (TransactionTag relation : relations) {
|
||||
Tag tag = tagMap.get(relation.getTagId());
|
||||
if (tag == null) {
|
||||
continue;
|
||||
}
|
||||
tagsByTransaction.computeIfAbsent(relation.getTransactionId(), key -> new ArrayList<>())
|
||||
.add(toTagVO(tag));
|
||||
}
|
||||
for (TransactionVO vo : voList) {
|
||||
vo.setTags(tagsByTransaction.getOrDefault(vo.getId(), List.of()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将标签实体转换为视图对象
|
||||
*
|
||||
* @param tag 标签实体
|
||||
* @return 标签视图对象
|
||||
*/
|
||||
private TagVO toTagVO(Tag tag) {
|
||||
TagVO vo = new TagVO();
|
||||
vo.setId(tag.getId());
|
||||
vo.setName(tag.getName());
|
||||
vo.setColor(tag.getColor());
|
||||
vo.setSortOrder(tag.getSortOrder());
|
||||
vo.setCreatedAt(tag.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用排序条件:默认按交易时间降序,支持按交易时间或金额升/降序。
|
||||
*
|
||||
* <p>排序字段与方向均采用白名单,非法值回退为交易时间降序,避免注入风险。</p>
|
||||
*
|
||||
* @param wrapper 查询条件
|
||||
* @param query 查询参数
|
||||
*/
|
||||
private void applySort(LambdaQueryWrapper<Transaction> wrapper, TransactionQuery query) {
|
||||
boolean asc = "asc".equalsIgnoreCase(query.getSortOrder());
|
||||
if ("amount".equals(query.getSortField())) {
|
||||
wrapper.orderBy(true, asc, Transaction::getAmount);
|
||||
} else if ("transactionTime".equals(query.getSortField())) {
|
||||
wrapper.orderBy(true, asc, Transaction::getTransactionTime);
|
||||
} else {
|
||||
wrapper.orderByDesc(Transaction::getTransactionTime);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验分类是否存在、归属当前用户且类型匹配
|
||||
*
|
||||
@@ -322,6 +528,7 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
vo.setRemarkHidden(t.getRemarkHidden() == null ? Boolean.FALSE : t.getRemarkHidden());
|
||||
vo.setTransactionTime(t.getTransactionTime());
|
||||
vo.setIncludeInStats(t.getIncludeInStats() == null ? Boolean.TRUE : t.getIncludeInStats());
|
||||
vo.setRawData(t.getRawData());
|
||||
vo.setCreatedAt(t.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
@@ -380,9 +587,14 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
if (StringUtils.hasText(query.getRemark())) {
|
||||
wrapper.like(Transaction::getRemark, query.getRemark());
|
||||
}
|
||||
// 按标签筛选:命中任一选中标签的记录
|
||||
if (query.getTagIds() != null && !query.getTagIds().isEmpty()) {
|
||||
wrapper.inSql(Transaction::getId, "SELECT transaction_id FROM t_transaction_tag WHERE tag_id IN ("
|
||||
+ query.getTagIds().stream().map(String::valueOf).collect(Collectors.joining(",")) + ")");
|
||||
}
|
||||
|
||||
// 按交易时间降序排列
|
||||
wrapper.orderByDesc(Transaction::getTransactionTime);
|
||||
// 应用排序:默认按交易时间降序,支持按交易时间/金额排序
|
||||
applySort(wrapper, query);
|
||||
|
||||
List<Transaction> transactions = transactionMapper.selectList(wrapper);
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.example.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 标签视图对象,用于向前端返回标签信息。
|
||||
*
|
||||
* @date 2026-09-17 16:26:52
|
||||
*/
|
||||
@Data
|
||||
public class TagVO {
|
||||
|
||||
/** 标签ID */
|
||||
private Long id;
|
||||
|
||||
/** 标签名称 */
|
||||
private String name;
|
||||
|
||||
/** 标签颜色 */
|
||||
private String color;
|
||||
|
||||
/** 排序序号 */
|
||||
private Integer sortOrder;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 交易记录视图对象,用于向前端返回交易详情(包含关联的分类信息)
|
||||
@@ -44,6 +45,12 @@ public class TransactionVO {
|
||||
/** 是否计入统计 */
|
||||
private Boolean includeInStats;
|
||||
|
||||
/** 账单导入的原始完整数据(结构化JSON),手动记录为空 */
|
||||
private String rawData;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** 标签列表 */
|
||||
private List<TagVO> tags;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ CREATE TABLE IF NOT EXISTS t_transaction (
|
||||
external_no VARCHAR(64) COMMENT '外部账单交易号,用于账单导入去重',
|
||||
transaction_time DATETIME NOT NULL COMMENT '交易发生时间',
|
||||
include_in_stats TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否计入统计:1-计入,0-不计入(如借款、转账等)',
|
||||
raw_data JSON COMMENT '账单导入的原始完整数据(结构化JSON),手动记录为空',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
INDEX idx_user_id (user_id) COMMENT '用户ID索引',
|
||||
@@ -42,3 +43,25 @@ CREATE TABLE IF NOT EXISTS t_transaction (
|
||||
INDEX idx_user_time (user_id, transaction_time) COMMENT '用户ID+交易时间联合索引',
|
||||
UNIQUE INDEX uk_user_external (user_id, external_no) COMMENT '用户ID+外部交易号唯一索引,用于导入去重'
|
||||
);
|
||||
|
||||
-- 标签表:存储用户自定义标签,用于给交易打标签
|
||||
CREATE TABLE IF NOT EXISTS t_tag (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '标签ID,主键自增',
|
||||
user_id BIGINT NOT NULL COMMENT '所属用户ID',
|
||||
name VARCHAR(50) NOT NULL COMMENT '标签名称',
|
||||
color VARCHAR(20) COMMENT '标签颜色,用于前端展示',
|
||||
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序序号,数值越小越靠前',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
INDEX idx_user_id (user_id) COMMENT '用户ID索引',
|
||||
UNIQUE INDEX uk_user_name (user_id, name) COMMENT '用户ID+标签名唯一索引'
|
||||
);
|
||||
|
||||
-- 交易标签关联表:交易与标签的多对多关系
|
||||
CREATE TABLE IF NOT EXISTS t_transaction_tag (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关联ID,主键自增',
|
||||
transaction_id BIGINT NOT NULL COMMENT '交易记录ID',
|
||||
tag_id BIGINT NOT NULL COMMENT '标签ID',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
UNIQUE INDEX uk_transaction_tag (transaction_id, tag_id) COMMENT '交易+标签唯一索引',
|
||||
INDEX idx_tag_id (tag_id) COMMENT '标签ID索引'
|
||||
);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
-- 初始化默认标签种子脚本
|
||||
-- 用途:为现有用户补齐一套通用标签
|
||||
-- 特性:幂等,按「用户ID + 标签名称」去重,可重复执行
|
||||
-- 编码:utf8mb4
|
||||
|
||||
CREATE TEMPORARY TABLE IF NOT EXISTS tmp_default_tag (
|
||||
name VARCHAR(50) NOT NULL,
|
||||
color VARCHAR(20),
|
||||
sort_order INT NOT NULL
|
||||
);
|
||||
|
||||
DELETE FROM tmp_default_tag;
|
||||
|
||||
INSERT INTO tmp_default_tag (name, color, sort_order) VALUES
|
||||
('报销', '#2080f0', 10),
|
||||
('待报销', '#f0a020', 20),
|
||||
('旅行', '#18a058', 30),
|
||||
('家庭', '#8a2be2', 40),
|
||||
('工作', '#d03050', 50),
|
||||
('学习', '#0ea5e9', 60),
|
||||
('医疗', '#e11d48', 70),
|
||||
('大额消费', '#f97316', 80),
|
||||
('订阅', '#7c3aed', 90),
|
||||
('礼物', '#ec4899', 100),
|
||||
('投资', '#16a34a', 110),
|
||||
('宠物', '#a16207', 120),
|
||||
('电影', '#14b8a6', 130);
|
||||
|
||||
INSERT INTO t_tag (user_id, name, color, sort_order)
|
||||
SELECT u.id, d.name, d.color, d.sort_order
|
||||
FROM t_user u
|
||||
JOIN tmp_default_tag d
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM t_tag t
|
||||
WHERE t.user_id = u.id AND t.name = d.name
|
||||
);
|
||||
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_default_tag;
|
||||
Reference in New Issue
Block a user