feat: 账单关系(父子/同级)与关系图,AI 生成关系名称备注
- 新增 t_transaction_group、t_transaction_group_item 两张表(MySQL 与 SQLite 双脚本) - 后端新增关系组实体/枚举/Mapper/DTO/VO/Service/Controller:按账单查询、创建、更新、删除;校验同类型唯一、父子角色与成员归属;删除账单时级联清理(组不足或父子组失去父账单则整组移除);分页填充 relationGroupCount - 前端收支记录新增「关系」列与详情弹窗(基本信息、标签、账单关系、来源图片、原始数据) - 新增关系图(自定义 SVG 渲染):圆形节点、按收支红/绿着色、父子/同级连线带箭头与「关联」标签、当前账单高亮,成员较多时自动换行 - 关系编辑弹窗支持新建/编辑/删除、分页选择关联账单、AI 生成名称与备注、关系名称历史下拉(按类型过滤、聚焦即显示)、关系类型偏好记忆(localStorage) - 支持勾选账单批量建立关系,并新增批量选择模式(默认不显示多选列) - 新增 AI 关系能力:AiCallType.RELATION、AiRelationService 与提示词模板 ai-relation-system(含借贷场景),接口 POST /api/ai/relation - 新增 /api/transaction/group/* 接口与关系名称历史接口 - 补充中英日三语文案
This commit is contained in:
@@ -91,6 +91,21 @@ export function parseChannelByAi(text: string) {
|
||||
return request.post('/ai/channel', { text }, { timeout: 60000 })
|
||||
}
|
||||
|
||||
/** AI 生成的账单关系名称与备注建议 */
|
||||
export interface AiRelationResult {
|
||||
name: string
|
||||
remark: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
/** 根据关系类型与成员账单特征生成关系名称与备注(调用大模型,超时放宽) */
|
||||
export function parseRelationByAi(data: {
|
||||
type: string
|
||||
members: { type: string; channel?: string; categoryName?: string; remark?: string }[]
|
||||
}) {
|
||||
return request.post('/ai/relation', data, { timeout: 60000 })
|
||||
}
|
||||
|
||||
/** 根据表单当前内容生成单条分类与标签建议(调用大模型,超时放宽) */
|
||||
export function suggestOneByAi(data: { type: string; amount?: number | null; channel?: string; remark?: string }) {
|
||||
return request.post('/ai/suggest-one', data, { timeout: 60000 })
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// 账单关系相关 API 接口:账单之间父子/同级关系的查询与维护
|
||||
import request from './request'
|
||||
|
||||
/** 关系组内角色:PARENT-父账单,CHILD-子账单,MEMBER-成员 */
|
||||
export type TransactionGroupRole = 'PARENT' | 'CHILD' | 'MEMBER'
|
||||
|
||||
/** 关系类型:PARENT_CHILD-父子,SIBLING-同级 */
|
||||
export type TransactionRelationType = 'PARENT_CHILD' | 'SIBLING'
|
||||
|
||||
/** 关系组成员(含关联账单的展示快照) */
|
||||
export interface TransactionGroupItem {
|
||||
transactionId: number
|
||||
role: TransactionGroupRole
|
||||
sortOrder: number
|
||||
type: string
|
||||
amount: number
|
||||
channel: string
|
||||
remark: string
|
||||
remarkHidden: boolean
|
||||
transactionTime: string
|
||||
categoryName: string
|
||||
categoryIcon: string
|
||||
}
|
||||
|
||||
/** 账单关系组 */
|
||||
export interface TransactionGroup {
|
||||
id: number
|
||||
type: TransactionRelationType
|
||||
name: string
|
||||
remark: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
items: TransactionGroupItem[]
|
||||
}
|
||||
|
||||
/** 关系组成员提交项 */
|
||||
export interface TransactionGroupItemPayload {
|
||||
transactionId: number
|
||||
role?: TransactionGroupRole
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
/** 关系组成员草稿(前端编辑用,含账单展示快照) */
|
||||
export interface RelationMemberDraft {
|
||||
transactionId: number
|
||||
type: string
|
||||
amount: number
|
||||
channel: string
|
||||
remark: string
|
||||
transactionTime: string
|
||||
categoryName: string
|
||||
categoryIcon: string
|
||||
}
|
||||
|
||||
/** 关系组提交数据 */
|
||||
export interface TransactionGroupPayload {
|
||||
type: TransactionRelationType
|
||||
name?: string
|
||||
remark?: string
|
||||
items: TransactionGroupItemPayload[]
|
||||
}
|
||||
|
||||
/** 查询某笔账单所属的全部关系组 */
|
||||
export function getGroupsByTransaction(transactionId: number) {
|
||||
return request.get(`/transaction/group/by-transaction/${transactionId}`)
|
||||
}
|
||||
|
||||
/** 查询关系名称历史(可选按关系类型过滤),返回按最近使用排序的名称列表 */
|
||||
export function getGroupNames(type?: string) {
|
||||
return request.get('/transaction/group/names', { params: type ? { type } : {} })
|
||||
}
|
||||
|
||||
/** 创建账单关系组,返回新建关系组ID */
|
||||
export function createTransactionGroup(data: TransactionGroupPayload) {
|
||||
return request.post('/transaction/group', data)
|
||||
}
|
||||
|
||||
/** 更新账单关系组 */
|
||||
export function updateTransactionGroup(id: number, data: TransactionGroupPayload) {
|
||||
return request.put(`/transaction/group/${id}`, data)
|
||||
}
|
||||
|
||||
/** 删除账单关系组(仅解除关联,不删除账单) */
|
||||
export function deleteTransactionGroup(id: number) {
|
||||
return request.delete(`/transaction/group/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
<script setup lang="ts">
|
||||
// 账单详情弹窗组件:集中展示单条账单的基本信息、标签、账单关系、来源图片与原始数据
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useMessage, NModal, NSpace, NButton, NTag, NSpin, NDescriptions, NDescriptionsItem, NInput } from 'naive-ui'
|
||||
import { getAiImageBlob } from '../api/ai'
|
||||
import { getChannelLabel } from '../utils/channel'
|
||||
import TransactionRelationPanel from './TransactionRelationPanel.vue'
|
||||
|
||||
/** 详情弹窗展示的账单信息(由交易列表传入) */
|
||||
interface DetailTransaction {
|
||||
id: number
|
||||
type: string
|
||||
amount: number
|
||||
channel: string
|
||||
remark: string
|
||||
remarkHidden: boolean
|
||||
transactionTime: string
|
||||
includeInStats: boolean
|
||||
categoryName: string
|
||||
categoryIcon: string
|
||||
tags: { id: number; name: string; color: string }[]
|
||||
imageIds: number[]
|
||||
rawData: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 来源图片查看项:默认加载缩略图,可按需切换为原图 */
|
||||
interface ImageItem {
|
||||
id: number
|
||||
url: string
|
||||
isOriginal: boolean
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
transaction: DetailTransaction | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void
|
||||
(e: 'changed'): void
|
||||
(e: 'edit'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
|
||||
const imageItems = ref<ImageItem[]>([])
|
||||
const imageLoading = ref(false)
|
||||
|
||||
watch(() => props.show, (value) => {
|
||||
if (value) {
|
||||
loadImages()
|
||||
} else {
|
||||
clearImages()
|
||||
}
|
||||
})
|
||||
|
||||
// 切换账单时若弹窗已打开则重新加载来源图片
|
||||
watch(() => props.transaction?.id, () => {
|
||||
if (props.show) {
|
||||
loadImages()
|
||||
}
|
||||
})
|
||||
|
||||
/** 关闭弹窗(同步 v-model) */
|
||||
function onShowChange(value: boolean) {
|
||||
emit('update:show', value)
|
||||
}
|
||||
|
||||
/** 金额展示 */
|
||||
function formatAmount(item: { type: string; amount: number }) {
|
||||
return `${item.type === 'INCOME' ? '+' : '-'}${Number(item.amount ?? 0).toFixed(2)}`
|
||||
}
|
||||
|
||||
/** 时间展示 */
|
||||
function formatTime(time: string) {
|
||||
return time ? time.replace('T', ' ').substring(0, 16) : '-'
|
||||
}
|
||||
|
||||
/** 格式化原始数据为缩进 JSON */
|
||||
function formatRaw(raw: string | null) {
|
||||
if (!raw) return ''
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||
} catch (e) {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
/** 从接口响应中提取图片 Blob(blob 响应经拦截器后返回完整 axios response,需取其 data) */
|
||||
function extractBlob(res: any): Blob {
|
||||
return res?.data instanceof Blob ? res.data : (res instanceof Blob ? res : new Blob([res]))
|
||||
}
|
||||
|
||||
/** 加载来源图片缩略图(避免大图拖慢打开速度) */
|
||||
async function loadImages() {
|
||||
clearImages()
|
||||
const imageIds = props.transaction?.imageIds || []
|
||||
if (imageIds.length === 0) return
|
||||
imageLoading.value = true
|
||||
try {
|
||||
const items: ImageItem[] = []
|
||||
for (const id of imageIds) {
|
||||
const res: any = await getAiImageBlob(id, true)
|
||||
items.push({ id, url: URL.createObjectURL(extractBlob(res)), isOriginal: false, loading: false })
|
||||
}
|
||||
imageItems.value = items
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('common.loadFailed'))
|
||||
} finally {
|
||||
imageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 将指定来源图片切换为原图(替换并释放旧缩略图 objectURL) */
|
||||
async function loadOriginalImage(item: ImageItem) {
|
||||
if (item.isOriginal || item.loading) return
|
||||
item.loading = true
|
||||
try {
|
||||
const res: any = await getAiImageBlob(item.id)
|
||||
const url = URL.createObjectURL(extractBlob(res))
|
||||
URL.revokeObjectURL(item.url)
|
||||
item.url = url
|
||||
item.isOriginal = true
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('common.loadFailed'))
|
||||
} finally {
|
||||
item.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 释放来源图片 objectURL */
|
||||
function clearImages() {
|
||||
for (const item of imageItems.value) {
|
||||
URL.revokeObjectURL(item.url)
|
||||
}
|
||||
imageItems.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="t('transaction.detail.title')"
|
||||
style="width: 860px; max-width: 94vw;"
|
||||
@update:show="onShowChange"
|
||||
>
|
||||
<div v-if="transaction" style="max-height: 72vh; overflow-y: auto; padding-right: 4px;">
|
||||
<!-- 基本信息 -->
|
||||
<NDescriptions :column="2" bordered size="small" label-placement="left" style="margin-bottom: 16px;">
|
||||
<NDescriptionsItem :label="t('transaction.column.type')">
|
||||
<NTag :type="transaction.type === 'INCOME' ? 'success' : 'error'" size="small">
|
||||
{{ transaction.type === 'INCOME' ? t('common.types.income') : t('common.types.expense') }}
|
||||
</NTag>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem :label="t('transaction.column.amount')">
|
||||
<span :style="{ fontWeight: 600, color: transaction.type === 'INCOME' ? '#18a058' : '#d03050' }">
|
||||
{{ formatAmount(transaction) }}
|
||||
</span>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem :label="t('transaction.column.category')">
|
||||
{{ transaction.categoryIcon ? transaction.categoryIcon + ' ' : '' }}{{ transaction.categoryName || t('transaction.uncategorized') }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem :label="t('transaction.column.channel')">
|
||||
{{ getChannelLabel(transaction.channel) }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem :label="t('transaction.column.transactionTime')">
|
||||
{{ formatTime(transaction.transactionTime) }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem :label="t('transaction.includeInStats')">
|
||||
{{ transaction.includeInStats ? t('transaction.includeFilter.yes') : t('transaction.includeFilter.no') }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem :label="t('transaction.column.remark')" :span="2">
|
||||
{{ transaction.remark || '-' }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem :label="t('transaction.detail.createdAt')" :span="2">
|
||||
{{ formatTime(transaction.createdAt) }}
|
||||
</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
|
||||
<!-- 标签 -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">{{ t('transaction.column.tags') }}</div>
|
||||
<NSpace :size="4">
|
||||
<NTag
|
||||
v-for="tag in transaction.tags || []"
|
||||
:key="tag.id"
|
||||
size="small"
|
||||
:color="{ color: tag.color || '#2080f0', textColor: '#fff' }"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</NTag>
|
||||
<span v-if="!transaction.tags || transaction.tags.length === 0" style="color: #999;">{{ t('transaction.detail.noTags') }}</span>
|
||||
</NSpace>
|
||||
</div>
|
||||
|
||||
<!-- 账单关系 -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">{{ t('transaction.detail.relations') }}</div>
|
||||
<TransactionRelationPanel :transaction="transaction" @changed="emit('changed')" />
|
||||
</div>
|
||||
|
||||
<!-- 来源图片 -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">{{ t('transaction.detail.images') }}</div>
|
||||
<NSpin :show="imageLoading">
|
||||
<NSpace v-if="imageItems.length > 0" vertical :size="12">
|
||||
<div v-for="item in imageItems" :key="item.id">
|
||||
<img :src="item.url" alt="source image" style="width: 100%; max-height: 60vh; object-fit: contain; border-radius: 6px;" />
|
||||
<div style="margin-top: 6px; text-align: right;">
|
||||
<NTag v-if="item.isOriginal" size="small" type="success">{{ t('transaction.originalImage') }}</NTag>
|
||||
<NButton v-else size="small" :loading="item.loading" @click="loadOriginalImage(item)">
|
||||
{{ t('transaction.loadOriginal') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</NSpace>
|
||||
<span v-else-if="!imageLoading" style="color: #999;">{{ t('transaction.detail.noImages') }}</span>
|
||||
</NSpin>
|
||||
</div>
|
||||
|
||||
<!-- 原始数据 -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">{{ t('transaction.detail.rawData') }}</div>
|
||||
<NInput
|
||||
v-if="transaction.rawData"
|
||||
:value="formatRaw(transaction.rawData)"
|
||||
type="textarea"
|
||||
readonly
|
||||
:autosize="{ minRows: 6, maxRows: 18 }"
|
||||
/>
|
||||
<span v-else style="color: #999;">{{ t('transaction.detail.noRawData') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="emit('edit')">{{ t('common.edit') }}</NButton>
|
||||
<NButton @click="onShowChange(false)">{{ t('common.cancel') }}</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-section {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.detail-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,443 @@
|
||||
<script setup lang="ts">
|
||||
// 账单关系编辑弹窗组件:新建或编辑关系组(类型、名称、备注、成员与角色),供详情面板与列表勾选创建复用
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useMessage, NModal, NSpace, NButton, NForm, NFormItem, NInput, NAutoComplete, NRadioGroup, NRadio, NDataTable, NPagination } from 'naive-ui'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { createTransactionGroup, updateTransactionGroup, getGroupNames } from '../api/transactionGroup'
|
||||
import type { TransactionGroup, TransactionRelationType, TransactionGroupPayload, RelationMemberDraft } from '../api/transactionGroup'
|
||||
import { getTransactionPage } from '../api/transaction'
|
||||
import { getAiConfig, parseRelationByAi } from '../api/ai'
|
||||
import { getChannelLabel } from '../utils/channel'
|
||||
|
||||
/** 关系类型偏好存储键(界面偏好统一以 finance. 前缀命名) */
|
||||
const RELATION_TYPE_KEY = 'finance.transaction.relationType'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 弹窗显示状态 */
|
||||
show: boolean
|
||||
/** 编辑已有关系组时传入,创建时为 null */
|
||||
editingGroup: TransactionGroup | null
|
||||
/** 创建时的初始成员(预填) */
|
||||
initialMembers: RelationMemberDraft[]
|
||||
/** 不可移除的锚点账单ID(详情内新建时为当前账单),可为 null */
|
||||
anchorTransactionId: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
|
||||
// 编辑状态
|
||||
const editingGroupId = ref<number | null>(null)
|
||||
const editorType = ref<TransactionRelationType>('PARENT_CHILD')
|
||||
const editorName = ref('')
|
||||
const editorRemark = ref('')
|
||||
const editorMembers = ref<RelationMemberDraft[]>([])
|
||||
const parentId = ref<number | null>(null)
|
||||
const saving = ref(false)
|
||||
const aiEnabled = ref(false) // AI 是否启用,用于控制生成入口显示
|
||||
const aiRelationLoading = ref(false) // AI 生成关系名称/备注请求状态
|
||||
const nameOptions = ref<{ label: string; value: string }[]>([]) // 关系名称历史下拉选项
|
||||
const nameMenuShow = ref(false) // 关系名称下拉显示状态(聚焦即显示)
|
||||
|
||||
// 关联账单选择弹窗状态
|
||||
const showPicker = ref(false)
|
||||
const pickerRows = ref<RelationMemberDraft[]>([])
|
||||
const pickerChecked = ref<number[]>([])
|
||||
const pickerPage = ref(1)
|
||||
const pickerSize = ref(10)
|
||||
const pickerTotal = ref(0)
|
||||
const pickerRemark = ref('')
|
||||
const pickerLoading = ref(false)
|
||||
|
||||
// 弹窗打开时按编辑对象或初始成员初始化表单
|
||||
watch(() => props.show, (value) => {
|
||||
if (value) {
|
||||
seed()
|
||||
loadNameHistory()
|
||||
} else {
|
||||
showPicker.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// 切换关系类型时刷新对应类型的历史名称,并在创建模式下记住用户偏好
|
||||
watch(editorType, () => {
|
||||
if (props.show) {
|
||||
loadNameHistory()
|
||||
if (editingGroupId.value === null) {
|
||||
savePreferredRelationType(editorType.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(loadAiConfig)
|
||||
|
||||
/** 读取上次使用的关系类型偏好,非法值或异常回退 PARENT_CHILD */
|
||||
function loadPreferredRelationType(): TransactionRelationType {
|
||||
try {
|
||||
const saved = localStorage.getItem(RELATION_TYPE_KEY)
|
||||
return saved === 'PARENT_CHILD' || saved === 'SIBLING' ? saved : 'PARENT_CHILD'
|
||||
} catch (e) {
|
||||
return 'PARENT_CHILD'
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存关系类型偏好,忽略存储异常 */
|
||||
function savePreferredRelationType(type: TransactionRelationType) {
|
||||
try {
|
||||
localStorage.setItem(RELATION_TYPE_KEY, type)
|
||||
} catch (e) {
|
||||
// 忽略存储异常
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载关系名称历史(按当前关系类型过滤) */
|
||||
async function loadNameHistory() {
|
||||
try {
|
||||
const res: any = await getGroupNames(editorType.value)
|
||||
nameOptions.value = (res.data || []).map((name: string) => ({ label: name, value: name }))
|
||||
} catch (e) {
|
||||
nameOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/** 关系名称下拉选项:按当前输入做不区分大小写的包含过滤,输入为空时显示全部历史 */
|
||||
const filteredNameOptions = computed(() => {
|
||||
const keyword = editorName.value.trim().toLowerCase()
|
||||
if (!keyword) {
|
||||
return nameOptions.value
|
||||
}
|
||||
return nameOptions.value.filter((option) => option.label.toLowerCase().includes(keyword))
|
||||
})
|
||||
|
||||
/** 读取 AI 启用状态,失败时按未启用处理 */
|
||||
async function loadAiConfig() {
|
||||
try {
|
||||
const res: any = await getAiConfig()
|
||||
aiEnabled.value = !!res.data?.enabled
|
||||
} catch (e) {
|
||||
aiEnabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化表单:编辑时取关系组内容,创建时取预填成员 */
|
||||
function seed() {
|
||||
if (props.editingGroup) {
|
||||
const group = props.editingGroup
|
||||
editingGroupId.value = group.id
|
||||
editorType.value = group.type
|
||||
editorName.value = group.name || ''
|
||||
editorRemark.value = group.remark || ''
|
||||
editorMembers.value = group.items.map((item) => ({
|
||||
transactionId: item.transactionId,
|
||||
type: item.type,
|
||||
amount: item.amount,
|
||||
channel: item.channel,
|
||||
remark: item.remark,
|
||||
transactionTime: item.transactionTime,
|
||||
categoryName: item.categoryName,
|
||||
categoryIcon: item.categoryIcon,
|
||||
}))
|
||||
const parent = group.items.find((item) => item.role === 'PARENT')
|
||||
parentId.value = parent ? parent.transactionId : null
|
||||
return
|
||||
}
|
||||
editingGroupId.value = null
|
||||
editorType.value = loadPreferredRelationType()
|
||||
editorName.value = ''
|
||||
editorRemark.value = ''
|
||||
editorMembers.value = props.initialMembers.map((item) => ({ ...item }))
|
||||
const anchor = props.anchorTransactionId
|
||||
if (anchor != null && editorMembers.value.some((item) => item.transactionId === anchor)) {
|
||||
parentId.value = anchor
|
||||
} else {
|
||||
parentId.value = editorMembers.value[0]?.transactionId ?? null
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭弹窗(同步 v-model) */
|
||||
function onShowChange(value: boolean) {
|
||||
emit('update:show', value)
|
||||
}
|
||||
|
||||
/** 金额展示 */
|
||||
function formatAmount(item: { type: string; amount: number }) {
|
||||
return `${item.type === 'INCOME' ? '+' : '-'}${Number(item.amount ?? 0).toFixed(2)}`
|
||||
}
|
||||
|
||||
/** 时间展示 */
|
||||
function formatTime(time: string) {
|
||||
return time ? time.replace('T', ' ').substring(0, 16) : '-'
|
||||
}
|
||||
|
||||
/** 从成员列表中移除 */
|
||||
function removeMember(transactionId: number) {
|
||||
editorMembers.value = editorMembers.value.filter((item) => item.transactionId !== transactionId)
|
||||
if (parentId.value === transactionId) {
|
||||
parentId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据关系类型与成员账单特征生成关系名称与备注(仅发送白名单字段,不含金额) */
|
||||
async function handleAiGenerate() {
|
||||
if (editorMembers.value.length < 2) {
|
||||
message.warning(t('ai.relation.needMembers'))
|
||||
return
|
||||
}
|
||||
aiRelationLoading.value = true
|
||||
try {
|
||||
const res: any = await parseRelationByAi({
|
||||
type: editorType.value,
|
||||
members: editorMembers.value.map((item) => ({
|
||||
type: item.type,
|
||||
channel: getChannelLabel(item.channel),
|
||||
categoryName: item.categoryName,
|
||||
remark: item.remark,
|
||||
})),
|
||||
})
|
||||
const data = res.data
|
||||
if (!data || !data.name) {
|
||||
message.warning(t('ai.relation.failed'))
|
||||
return
|
||||
}
|
||||
editorName.value = data.name
|
||||
if (data.remark) {
|
||||
editorRemark.value = data.remark
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('ai.relation.failed'))
|
||||
} finally {
|
||||
aiRelationLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存关系组 */
|
||||
async function handleSave() {
|
||||
if (editorMembers.value.length < 2) {
|
||||
message.warning(t('transaction.relation.minTwo'))
|
||||
return
|
||||
}
|
||||
if (editorType.value === 'PARENT_CHILD' && !editorMembers.value.some((item) => item.transactionId === parentId.value)) {
|
||||
message.warning(t('transaction.relation.needParent'))
|
||||
return
|
||||
}
|
||||
const payload: TransactionGroupPayload = {
|
||||
type: editorType.value,
|
||||
name: editorName.value || undefined,
|
||||
remark: editorRemark.value || undefined,
|
||||
items: editorMembers.value.map((item, index) => ({
|
||||
transactionId: item.transactionId,
|
||||
role: editorType.value === 'PARENT_CHILD'
|
||||
? (item.transactionId === parentId.value ? 'PARENT' : 'CHILD')
|
||||
: 'MEMBER',
|
||||
sortOrder: index,
|
||||
})),
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingGroupId.value) {
|
||||
await updateTransactionGroup(editingGroupId.value, payload)
|
||||
message.success(t('common.updateSuccess'))
|
||||
} else {
|
||||
await createTransactionGroup(payload)
|
||||
message.success(t('common.createSuccess'))
|
||||
}
|
||||
emit('saved')
|
||||
emit('update:show', false)
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('transaction.relation.saveFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开选择账单弹窗 */
|
||||
function openPicker() {
|
||||
pickerRemark.value = ''
|
||||
showPicker.value = true
|
||||
loadPicker(1)
|
||||
}
|
||||
|
||||
/** 加载候选账单 */
|
||||
async function loadPicker(page: number) {
|
||||
pickerPage.value = page
|
||||
pickerLoading.value = true
|
||||
try {
|
||||
const res: any = await getTransactionPage({
|
||||
page: pickerPage.value,
|
||||
size: pickerSize.value,
|
||||
remark: pickerRemark.value || undefined,
|
||||
})
|
||||
const data = res.data || {}
|
||||
pickerRows.value = (data.records || []).map((row: any) => ({
|
||||
transactionId: row.id,
|
||||
type: row.type,
|
||||
amount: row.amount,
|
||||
channel: row.channel,
|
||||
remark: row.remark,
|
||||
transactionTime: row.transactionTime,
|
||||
categoryName: row.categoryName,
|
||||
categoryIcon: row.categoryIcon,
|
||||
}))
|
||||
pickerTotal.value = data.total || 0
|
||||
pickerChecked.value = []
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('common.loadFailed'))
|
||||
} finally {
|
||||
pickerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 确认选择候选账单 */
|
||||
function confirmPick() {
|
||||
const existing = new Set(editorMembers.value.map((item) => item.transactionId))
|
||||
for (const id of pickerChecked.value) {
|
||||
if (existing.has(id)) continue
|
||||
const row = pickerRows.value.find((item) => item.transactionId === id)
|
||||
if (row) {
|
||||
editorMembers.value.push({ ...row })
|
||||
existing.add(id)
|
||||
}
|
||||
}
|
||||
showPicker.value = false
|
||||
}
|
||||
|
||||
/** 候选账单表格列 */
|
||||
const pickerColumns = computed<DataTableColumns<RelationMemberDraft>>(() => [
|
||||
{ type: 'selection', disabled: (row) => row.transactionId === props.anchorTransactionId },
|
||||
{ title: t('transaction.column.transactionTime'), key: 'transactionTime', width: 140, render: (row) => formatTime(row.transactionTime) },
|
||||
{
|
||||
title: t('transaction.column.type'), key: 'type', width: 70,
|
||||
render: (row) => (row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')),
|
||||
},
|
||||
{ title: t('transaction.column.category'), key: 'categoryName', width: 110, render: (row) => row.categoryName || t('transaction.uncategorized') },
|
||||
{
|
||||
title: t('transaction.column.amount'), key: 'amount', width: 100,
|
||||
render: (row) => formatAmount(row),
|
||||
},
|
||||
{ title: t('transaction.column.channel'), key: 'channel', width: 90, render: (row) => getChannelLabel(row.channel) },
|
||||
{ title: t('transaction.column.remark'), key: 'remark' },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 新建/编辑关系弹窗 -->
|
||||
<NModal
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="editingGroupId ? t('transaction.relation.editTitle') : t('transaction.relation.createTitle')"
|
||||
style="width: 780px; max-width: 94vw;"
|
||||
@update:show="onShowChange"
|
||||
>
|
||||
<NForm>
|
||||
<NFormItem :label="t('transaction.relation.type')">
|
||||
<NRadioGroup v-model:value="editorType">
|
||||
<NRadio value="PARENT_CHILD">{{ t('transaction.relation.typeParentChild') }}</NRadio>
|
||||
<NRadio value="SIBLING">{{ t('transaction.relation.typeSibling') }}</NRadio>
|
||||
</NRadioGroup>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('transaction.relation.name')">
|
||||
<div style="display: flex; align-items: center; gap: 8px; width: 100%;">
|
||||
<NAutoComplete
|
||||
v-model:value="editorName"
|
||||
:options="filteredNameOptions"
|
||||
:get-show="() => nameMenuShow"
|
||||
:placeholder="t('transaction.relation.namePlaceholder')"
|
||||
style="flex: 1;"
|
||||
@focus="nameMenuShow = true"
|
||||
@blur="nameMenuShow = false"
|
||||
@select="nameMenuShow = false"
|
||||
/>
|
||||
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiRelationLoading" @click="handleAiGenerate">
|
||||
{{ t('ai.relation.button') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('transaction.relation.remark')">
|
||||
<NInput v-model:value="editorRemark" :placeholder="t('transaction.remarkPlaceholder')" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('transaction.relation.members')">
|
||||
<div style="width: 100%;">
|
||||
<NSpace vertical :size="6">
|
||||
<div
|
||||
v-for="member in editorMembers"
|
||||
:key="member.transactionId"
|
||||
style="display: flex; align-items: center; gap: 8px; font-size: 13px;"
|
||||
>
|
||||
<span style="min-width: 130px;">{{ formatTime(member.transactionTime) }}</span>
|
||||
<span style="min-width: 100px;">{{ member.categoryName || t('transaction.uncategorized') }}</span>
|
||||
<span style="flex: 1; color: #999; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ member.remark || '' }}</span>
|
||||
<span style="font-weight: 600;">{{ formatAmount(member) }}</span>
|
||||
<NRadio
|
||||
v-if="editorType === 'PARENT_CHILD'"
|
||||
:checked="parentId === member.transactionId"
|
||||
@update:checked="() => (parentId = member.transactionId)"
|
||||
>
|
||||
{{ t('transaction.relation.roleParent') }}
|
||||
</NRadio>
|
||||
<NButton
|
||||
size="tiny"
|
||||
:disabled="member.transactionId === anchorTransactionId"
|
||||
@click="removeMember(member.transactionId)"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</NSpace>
|
||||
<NButton size="small" dashed style="margin-top: 8px;" @click="openPicker">
|
||||
+ {{ t('transaction.relation.addMember') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
<NButton type="primary" block :loading="saving" @click="handleSave">{{ t('common.confirm') }}</NButton>
|
||||
</NModal>
|
||||
|
||||
<!-- 选择关联账单弹窗 -->
|
||||
<NModal
|
||||
v-model:show="showPicker"
|
||||
preset="card"
|
||||
:title="t('transaction.relation.pickTitle')"
|
||||
style="width: 820px; max-width: 94vw;"
|
||||
>
|
||||
<NSpace align="center" style="margin-bottom: 12px;">
|
||||
<NInput
|
||||
v-model:value="pickerRemark"
|
||||
:placeholder="t('transaction.column.remark')"
|
||||
clearable
|
||||
style="width: 220px;"
|
||||
@keyup.enter="loadPicker(1)"
|
||||
/>
|
||||
<NButton @click="loadPicker(1)">{{ t('transaction.search') }}</NButton>
|
||||
</NSpace>
|
||||
<NDataTable
|
||||
v-model:checked-row-keys="pickerChecked"
|
||||
:columns="pickerColumns"
|
||||
:data="pickerRows"
|
||||
:loading="pickerLoading"
|
||||
:row-key="(row: RelationMemberDraft) => row.transactionId"
|
||||
:max-height="420"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
/>
|
||||
<NSpace justify="space-between" align="center" style="margin-top: 12px;">
|
||||
<NPagination
|
||||
v-model:page="pickerPage"
|
||||
:page-size="pickerSize"
|
||||
:item-count="pickerTotal"
|
||||
@update:page="loadPicker"
|
||||
/>
|
||||
<NSpace>
|
||||
<NButton @click="showPicker = false">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" @click="confirmPick">{{ t('common.confirm') }}</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
// 账单关系弹窗组件:包裹关系面板,供交易列表「关系」列快速查看与维护某笔账单的关系
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NModal } from 'naive-ui'
|
||||
import TransactionRelationPanel from './TransactionRelationPanel.vue'
|
||||
|
||||
/** 当前账单的精简信息 */
|
||||
interface CurrentTransaction {
|
||||
id: number
|
||||
type: string
|
||||
amount: number
|
||||
channel: string
|
||||
remark: string
|
||||
transactionTime: string
|
||||
categoryName: string
|
||||
categoryIcon: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
transaction: CurrentTransaction | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void
|
||||
(e: 'changed'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/** 同步弹窗显示状态 */
|
||||
function onShowChange(value: boolean) {
|
||||
emit('update:show', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="t('transaction.relation.title')"
|
||||
style="width: 860px; max-width: 94vw;"
|
||||
@update:show="onShowChange"
|
||||
>
|
||||
<TransactionRelationPanel :transaction="transaction" @changed="emit('changed')" />
|
||||
</NModal>
|
||||
</template>
|
||||
@@ -0,0 +1,472 @@
|
||||
<script setup lang="ts">
|
||||
// 账单关系面板组件:展示某笔账单的父子/同级关系,并支持新建、编辑、删除关系组(内嵌于详情弹窗)
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useMessage, useDialog, NCard, NSpace, NButton, NTag, NSpin, NEmpty } from 'naive-ui'
|
||||
import { getGroupsByTransaction, deleteTransactionGroup } from '../api/transactionGroup'
|
||||
import type { TransactionGroup, TransactionGroupItem, RelationMemberDraft } from '../api/transactionGroup'
|
||||
import TransactionRelationEditorModal from './TransactionRelationEditorModal.vue'
|
||||
|
||||
/** 当前账单的精简信息(由详情弹窗传入) */
|
||||
interface CurrentTransaction {
|
||||
id: number
|
||||
type: string
|
||||
amount: number
|
||||
channel: string
|
||||
remark: string
|
||||
transactionTime: string
|
||||
categoryName: string
|
||||
categoryIcon: string
|
||||
}
|
||||
|
||||
/** SVG 画布逻辑宽度(配合 preserveAspectRatio 自适应缩放,避免裁切) */
|
||||
const VB_W = 820
|
||||
/** 普通节点半径 */
|
||||
const NODE_R = 32
|
||||
/** 当前账单节点半径(描边高亮) */
|
||||
const NODE_R_CURRENT = 36
|
||||
/** 连线端点与节点之间预留的间距(为箭头留出空间) */
|
||||
const EDGE_MARGIN = 12
|
||||
/** 左右安全边距 */
|
||||
const PADDING_X = 60
|
||||
/** 同行相邻节点圆心间距(大于节点直径,避免重叠) */
|
||||
const SPACING_X = 92
|
||||
/** 相邻两行圆心间距 */
|
||||
const ROW_GAP = 84
|
||||
/** 支出/流入颜色:支出红、收入绿 */
|
||||
const COLOR_EXPENSE = '#d03050'
|
||||
const COLOR_INCOME = '#18a058'
|
||||
|
||||
/** 关系图节点 */
|
||||
interface LayoutNode {
|
||||
item: TransactionGroupItem
|
||||
cx: number
|
||||
cy: number
|
||||
r: number
|
||||
color: string
|
||||
isCurrent: boolean
|
||||
amountText: string
|
||||
roleText: string
|
||||
}
|
||||
|
||||
/** 关系图连线 */
|
||||
interface LayoutEdge {
|
||||
x1: number
|
||||
y1: number
|
||||
x2: number
|
||||
y2: number
|
||||
}
|
||||
|
||||
/** 关系图边标签 */
|
||||
interface LayoutLabel {
|
||||
x: number
|
||||
y: number
|
||||
text: string
|
||||
}
|
||||
|
||||
/** 单个关系组的布局结果 */
|
||||
interface GraphLayout {
|
||||
width: number
|
||||
height: number
|
||||
nodes: LayoutNode[]
|
||||
edges: LayoutEdge[]
|
||||
labels: LayoutLabel[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
transaction: CurrentTransaction | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'changed'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
// 关系组列表状态
|
||||
const groups = ref<TransactionGroup[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 编辑弹窗状态
|
||||
const showEditor = ref(false)
|
||||
const editingGroup = ref<TransactionGroup | null>(null)
|
||||
const initialMembers = ref<RelationMemberDraft[]>([])
|
||||
|
||||
onMounted(loadGroups)
|
||||
// 切换查看的账单时重新加载关系
|
||||
watch(() => props.transaction?.id, () => {
|
||||
showEditor.value = false
|
||||
loadGroups()
|
||||
})
|
||||
|
||||
/** 加载当前账单的关系组 */
|
||||
async function loadGroups() {
|
||||
if (!props.transaction) {
|
||||
groups.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await getGroupsByTransaction(props.transaction.id)
|
||||
groups.value = res.data || []
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('transaction.relation.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 关系组与布局的对应列表(布局随语言/数据响应式重算) */
|
||||
const groupLayouts = computed(() => groups.value.map((group) => ({ group, layout: buildLayout(group) })))
|
||||
|
||||
/** 按收支方向取色:支出红、收入绿 */
|
||||
function nodeColor(item: { type: string }) {
|
||||
return item.type === 'INCOME' ? COLOR_INCOME : COLOR_EXPENSE
|
||||
}
|
||||
|
||||
/** 角色文案 */
|
||||
function roleLabel(role: string) {
|
||||
if (role === 'PARENT') return t('transaction.relation.roleParent')
|
||||
if (role === 'CHILD') return t('transaction.relation.roleChild')
|
||||
return t('transaction.relation.roleMember')
|
||||
}
|
||||
|
||||
/** 金额展示(成员列表用) */
|
||||
function formatAmount(item: { type: string; amount: number }) {
|
||||
return `${item.type === 'INCOME' ? '+' : '-'}${Number(item.amount ?? 0).toFixed(2)}`
|
||||
}
|
||||
|
||||
/** 节点内金额:绝对值 ≥ 1000 时省略小数,避免文字拥挤 */
|
||||
function formatNodeAmount(item: { type: string; amount: number }) {
|
||||
const value = Number(item.amount ?? 0)
|
||||
const text = Math.abs(value) >= 1000 ? value.toFixed(0) : value.toFixed(2)
|
||||
return `${item.type === 'INCOME' ? '+' : '-'}${text}`
|
||||
}
|
||||
|
||||
/** 时间展示 */
|
||||
function formatTime(time: string) {
|
||||
return time ? time.replace('T', ' ').substring(0, 16) : '-'
|
||||
}
|
||||
|
||||
/** 节点下方说明:优先备注,其次分类,超长截断 */
|
||||
function caption(item: TransactionGroupItem) {
|
||||
const text = item.remark || item.categoryName || ''
|
||||
return text.length > 10 ? text.slice(0, 10) + '…' : text
|
||||
}
|
||||
|
||||
/** 节点原生 tooltip 文本 */
|
||||
function tooltipText(item: TransactionGroupItem) {
|
||||
const typeLabel = item.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')
|
||||
return [
|
||||
`${typeLabel} ${Number(item.amount ?? 0).toFixed(2)}`,
|
||||
`${t('transaction.column.category')}: ${item.categoryName || t('transaction.uncategorized')}`,
|
||||
`${t('transaction.column.remark')}: ${item.remark || '-'}`,
|
||||
`${t('transaction.column.transactionTime')}: ${formatTime(item.transactionTime)}`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** 每行最多可放置的节点数(按固定间距计算,保证不重叠) */
|
||||
function nodesPerRow(): number {
|
||||
const available = VB_W - PADDING_X * 2
|
||||
return Math.max(1, Math.floor(available / SPACING_X) + 1)
|
||||
}
|
||||
|
||||
/** 将列表按每行容量切分为多行 */
|
||||
function chunkRows<T>(list: T[], size: number): T[][] {
|
||||
const rows: T[][] = []
|
||||
for (let i = 0; i < list.length; i += size) {
|
||||
rows.push(list.slice(i, i + size))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** 在画布宽度内居中均匀分布一行节点,返回各节点 x 坐标 */
|
||||
function rowXs(count: number): number[] {
|
||||
if (count <= 0) return []
|
||||
if (count === 1) return [VB_W / 2]
|
||||
const span = SPACING_X * (count - 1)
|
||||
const startX = (VB_W - span) / 2
|
||||
return Array.from({ length: count }, (_, index) => startX + index * SPACING_X)
|
||||
}
|
||||
|
||||
/** 构建布局节点 */
|
||||
function makeLayoutNode(item: TransactionGroupItem, cx: number, cy: number, roleText: string): LayoutNode {
|
||||
const isCurrent = item.transactionId === props.transaction?.id
|
||||
return {
|
||||
item,
|
||||
cx,
|
||||
cy,
|
||||
r: isCurrent ? NODE_R_CURRENT : NODE_R,
|
||||
color: nodeColor(item),
|
||||
isCurrent,
|
||||
amountText: formatNodeAmount(item),
|
||||
roleText,
|
||||
}
|
||||
}
|
||||
|
||||
/** 计算两节点之间带端点间距的连线(为箭头预留空间) */
|
||||
function edgeBetween(from: LayoutNode, to: LayoutNode): LayoutEdge {
|
||||
const dx = to.cx - from.cx
|
||||
const dy = to.cy - from.cy
|
||||
const len = Math.hypot(dx, dy) || 1
|
||||
const ux = dx / len
|
||||
const uy = dy / len
|
||||
return {
|
||||
x1: from.cx + ux * (from.r + 2),
|
||||
y1: from.cy + uy * (from.r + 2),
|
||||
x2: to.cx - ux * (to.r + EDGE_MARGIN),
|
||||
y2: to.cy - uy * (to.r + EDGE_MARGIN),
|
||||
}
|
||||
}
|
||||
|
||||
/** 构建某个关系组的 SVG 布局(成员较多时自动换行,避免节点重叠) */
|
||||
function buildLayout(group: TransactionGroup): GraphLayout {
|
||||
const nodes: LayoutNode[] = []
|
||||
const edges: LayoutEdge[] = []
|
||||
const labels: LayoutLabel[] = []
|
||||
const perRow = nodesPerRow()
|
||||
let lastCy = 0
|
||||
|
||||
if (group.type === 'PARENT_CHILD') {
|
||||
const parent = group.items.find((item) => item.role === 'PARENT')
|
||||
const children = group.items.filter((item) => item.role !== 'PARENT')
|
||||
const parentNode = parent ? makeLayoutNode(parent, VB_W / 2, 52, roleLabel(parent.role)) : null
|
||||
if (parentNode) {
|
||||
nodes.push(parentNode)
|
||||
}
|
||||
let cy = parentNode ? 156 : 70
|
||||
chunkRows(children, perRow).forEach((rowItems) => {
|
||||
const xs = rowXs(rowItems.length)
|
||||
rowItems.forEach((child, index) => {
|
||||
const childNode = makeLayoutNode(child, xs[index], cy, roleLabel(child.role))
|
||||
nodes.push(childNode)
|
||||
if (parentNode) {
|
||||
edges.push(edgeBetween(parentNode, childNode))
|
||||
}
|
||||
})
|
||||
lastCy = cy
|
||||
cy += ROW_GAP
|
||||
})
|
||||
} else {
|
||||
const nodeList: LayoutNode[] = []
|
||||
let cy = 70
|
||||
chunkRows(group.items, perRow).forEach((rowItems) => {
|
||||
const xs = rowXs(rowItems.length)
|
||||
rowItems.forEach((item, index) => {
|
||||
nodeList.push(makeLayoutNode(item, xs[index], cy, ''))
|
||||
})
|
||||
lastCy = cy
|
||||
cy += ROW_GAP
|
||||
})
|
||||
nodeList.forEach((node) => nodes.push(node))
|
||||
for (let i = 1; i < nodeList.length; i++) {
|
||||
edges.push(edgeBetween(nodeList[i - 1], nodeList[i]))
|
||||
labels.push({
|
||||
x: (nodeList[i - 1].cx + nodeList[i].cx) / 2,
|
||||
y: (nodeList[i - 1].cy + nodeList[i].cy) / 2 - 10,
|
||||
text: t('transaction.relation.edgeLabel'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const height = Math.max(lastCy + NODE_R + 34, 180)
|
||||
return { width: VB_W, height, nodes, edges, labels }
|
||||
}
|
||||
|
||||
/** 由当前账单构造成员草稿 */
|
||||
function currentAsMember(): RelationMemberDraft {
|
||||
const tx = props.transaction!
|
||||
return {
|
||||
transactionId: tx.id,
|
||||
type: tx.type,
|
||||
amount: tx.amount,
|
||||
channel: tx.channel,
|
||||
remark: tx.remark,
|
||||
transactionTime: tx.transactionTime,
|
||||
categoryName: tx.categoryName,
|
||||
categoryIcon: tx.categoryIcon,
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开新建关系弹窗 */
|
||||
function openCreate() {
|
||||
editingGroup.value = null
|
||||
initialMembers.value = props.transaction ? [currentAsMember()] : []
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
/** 打开编辑关系弹窗 */
|
||||
function openEdit(group: TransactionGroup) {
|
||||
editingGroup.value = group
|
||||
initialMembers.value = []
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
/** 编辑保存后刷新关系组并通知外部 */
|
||||
async function onEditorSaved() {
|
||||
await loadGroups()
|
||||
emit('changed')
|
||||
}
|
||||
|
||||
/** 删除关系组 */
|
||||
function handleDeleteGroup(group: TransactionGroup) {
|
||||
dialog.warning({
|
||||
title: t('common.confirmDelete'),
|
||||
content: t('transaction.relation.deleteConfirm'),
|
||||
positiveText: t('common.confirm'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await deleteTransactionGroup(group.id)
|
||||
message.success(t('common.deleteSuccess'))
|
||||
await loadGroups()
|
||||
emit('changed')
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('transaction.relation.deleteFailed'))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: flex-end; margin-bottom: 8px;">
|
||||
<NButton type="primary" size="small" @click="openCreate">{{ t('transaction.relation.create') }}</NButton>
|
||||
</div>
|
||||
|
||||
<NSpin :show="loading">
|
||||
<NEmpty v-if="groups.length === 0" :description="t('transaction.relation.empty')" style="padding: 16px 0;" />
|
||||
|
||||
<NSpace v-else vertical :size="12">
|
||||
<NCard v-for="entry in groupLayouts" :key="entry.group.id" size="small" embedded>
|
||||
<template #header>
|
||||
<NSpace align="center" :size="8">
|
||||
<NTag :type="entry.group.type === 'PARENT_CHILD' ? 'warning' : 'info'" size="small">
|
||||
{{ entry.group.type === 'PARENT_CHILD' ? t('transaction.relation.typeParentChild') : t('transaction.relation.typeSibling') }}
|
||||
</NTag>
|
||||
<span style="font-weight: 600;">{{ entry.group.name || '-' }}</span>
|
||||
</NSpace>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<NSpace :size="4">
|
||||
<NButton size="tiny" @click="openEdit(entry.group)">{{ t('common.edit') }}</NButton>
|
||||
<NButton size="tiny" type="error" @click="handleDeleteGroup(entry.group)">{{ t('common.delete') }}</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<svg class="relation-graph" :viewBox="`0 0 ${entry.layout.width} ${entry.layout.height}`" preserveAspectRatio="xMidYMid meet">
|
||||
<defs>
|
||||
<marker
|
||||
:id="`rel-arrow-${entry.group.id}`"
|
||||
markerWidth="8"
|
||||
markerHeight="8"
|
||||
refX="7"
|
||||
refY="4"
|
||||
orient="auto"
|
||||
markerUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M0,0 L8,4 L0,8 z" fill="#b0b6bd" />
|
||||
</marker>
|
||||
</defs>
|
||||
<line
|
||||
v-for="(edge, index) in entry.layout.edges"
|
||||
:key="`edge-${index}`"
|
||||
:x1="edge.x1"
|
||||
:y1="edge.y1"
|
||||
:x2="edge.x2"
|
||||
:y2="edge.y2"
|
||||
stroke="#c0c4cc"
|
||||
stroke-width="1.5"
|
||||
:marker-end="`url(#rel-arrow-${entry.group.id})`"
|
||||
/>
|
||||
<text
|
||||
v-for="(label, index) in entry.layout.labels"
|
||||
:key="`label-${index}`"
|
||||
:x="label.x"
|
||||
:y="label.y"
|
||||
text-anchor="middle"
|
||||
font-size="11"
|
||||
fill="#909399"
|
||||
>
|
||||
{{ label.text }}
|
||||
</text>
|
||||
<g v-for="node in entry.layout.nodes" :key="node.item.transactionId">
|
||||
<title>{{ tooltipText(node.item) }}</title>
|
||||
<circle
|
||||
:cx="node.cx"
|
||||
:cy="node.cy"
|
||||
:r="node.r"
|
||||
:fill="node.color"
|
||||
:stroke="node.isCurrent ? '#2080f0' : '#ffffff'"
|
||||
:stroke-width="node.isCurrent ? 3 : 1.5"
|
||||
/>
|
||||
<text
|
||||
:x="node.cx"
|
||||
:y="node.cy + 4"
|
||||
text-anchor="middle"
|
||||
font-size="11"
|
||||
font-weight="600"
|
||||
fill="#ffffff"
|
||||
>
|
||||
{{ node.amountText }}
|
||||
</text>
|
||||
<text
|
||||
v-if="node.roleText"
|
||||
:x="node.cx"
|
||||
:y="node.cy - node.r - 10"
|
||||
text-anchor="middle"
|
||||
font-size="10"
|
||||
fill="#606266"
|
||||
>
|
||||
{{ node.roleText }}
|
||||
</text>
|
||||
<text
|
||||
:x="node.cx"
|
||||
:y="node.cy + node.r + 16"
|
||||
text-anchor="middle"
|
||||
font-size="11"
|
||||
fill="#909399"
|
||||
>
|
||||
{{ caption(node.item) }}
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<NSpace vertical :size="4" style="margin-top: 8px;">
|
||||
<div v-for="item in entry.group.items" :key="item.transactionId" style="font-size: 13px;">
|
||||
<NTag size="tiny" :bordered="false" :color="{ color: nodeColor(item), textColor: '#fff' }">
|
||||
{{ roleLabel(item.role) }}
|
||||
</NTag>
|
||||
<span style="margin-left: 8px;">{{ formatTime(item.transactionTime) }}</span>
|
||||
<span style="margin-left: 8px;">{{ item.categoryName || t('transaction.uncategorized') }}</span>
|
||||
<span style="margin-left: 8px; color: #999;">{{ item.remark || '' }}</span>
|
||||
<span style="margin-left: 8px; font-weight: 600;">{{ formatAmount(item) }}</span>
|
||||
</div>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
</NSpace>
|
||||
</NSpin>
|
||||
|
||||
<!-- 新建/编辑关系弹窗 -->
|
||||
<TransactionRelationEditorModal
|
||||
v-model:show="showEditor"
|
||||
:editing-group="editingGroup"
|
||||
:initial-members="initialMembers"
|
||||
:anchor-transaction-id="transaction?.id ?? null"
|
||||
@saved="onEditorSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 关系图:随容器宽度等比缩放;成员较多时高度随行数增加,过高则整体缩放,避免节点重叠 */
|
||||
.relation-graph {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 420px;
|
||||
}
|
||||
</style>
|
||||
@@ -369,6 +369,8 @@ export default {
|
||||
amountAsc: { zh: '金额(低→高)', en: 'Amount (Low to High)', ja: '金額(低い順)' },
|
||||
},
|
||||
batchSet: { zh: '一键设置', en: 'Batch Set', ja: '一括設定' },
|
||||
batchMode: { zh: '批量操作', en: 'Batch', ja: '一括操作' },
|
||||
exitBatchMode: { zh: '退出批量', en: 'Exit Batch', ja: '一括終了' },
|
||||
importBill: { zh: '导入账单', en: 'Import Bill', ja: '明細をインポート' },
|
||||
exportExcel: { zh: '导出 Excel', en: 'Export Excel', ja: 'Excel をエクスポート' },
|
||||
addRecord: { zh: '新增记录', en: 'Add Record', ja: '記録を追加' },
|
||||
@@ -462,6 +464,49 @@ export default {
|
||||
yes: { zh: '计入', en: 'Included', ja: '含める' },
|
||||
no: { zh: '不计入', en: 'Excluded', ja: '含めない' },
|
||||
},
|
||||
relation: {
|
||||
column: { zh: '关系', en: 'Relation', ja: '関連' },
|
||||
viewCount: { zh: '{count} 个关系', en: '{count} relation(s)', ja: '{count} 件の関連' },
|
||||
none: { zh: '-', en: '-', ja: '-' },
|
||||
createFromSelection: { zh: '建立关系', en: 'Create Relation', ja: '関連を作成' },
|
||||
selectAtLeastTwo: { zh: '请至少勾选两条账单', en: 'Please select at least two bills', ja: '明細を2件以上選択してください' },
|
||||
title: { zh: '账单关系', en: 'Bill Relations', ja: '明細の関連' },
|
||||
create: { zh: '新建关系', en: 'New Relation', ja: '関連を作成' },
|
||||
createTitle: { zh: '新建账单关系', en: 'New Bill Relation', ja: '明細の関連を作成' },
|
||||
editTitle: { zh: '编辑账单关系', en: 'Edit Bill Relation', ja: '明細の関連を編集' },
|
||||
empty: { zh: '暂无关联账单', en: 'No related bills', ja: '関連する明細はありません' },
|
||||
type: { zh: '关系类型', en: 'Relation Type', ja: '関連タイプ' },
|
||||
typeParentChild: { zh: '父子关系', en: 'Parent-Child', ja: '親子関係' },
|
||||
typeSibling: { zh: '同级关系', en: 'Sibling', ja: '同級関係' },
|
||||
name: { zh: '关系名称', en: 'Relation Name', ja: '関連名' },
|
||||
namePlaceholder: { zh: '可选,如「交通卡充值」', en: 'Optional, e.g. "Transit card top-up"', ja: '任意(例:交通カードのチャージ)' },
|
||||
remark: { zh: '备注', en: 'Remark', ja: '備考' },
|
||||
members: { zh: '关联账单', en: 'Related Bills', ja: '関連する明細' },
|
||||
addMember: { zh: '添加账单', en: 'Add Bill', ja: '明細を追加' },
|
||||
pickTitle: { zh: '选择关联账单', en: 'Select Bills', ja: '明細を選択' },
|
||||
roleParent: { zh: '父账单', en: 'Parent', ja: '親' },
|
||||
roleChild: { zh: '子账单', en: 'Child', ja: '子' },
|
||||
roleMember: { zh: '成员', en: 'Member', ja: 'メンバー' },
|
||||
edgeLabel: { zh: '关联', en: 'Linked', ja: '関連' },
|
||||
minTwo: { zh: '关系组至少包含两条账单', en: 'A relation group needs at least two bills', ja: '関連グループには最低2件の明細が必要です' },
|
||||
needParent: { zh: '请指定一条父账单', en: 'Please select one parent bill', ja: '親の明細を1件指定してください' },
|
||||
deleteConfirm: { zh: '确定删除该关系组?仅解除关联,不会删除账单', en: 'Delete this relation group? Only the relation is removed, not the bills', ja: 'この関連グループを削除しますか?明細は削除されません' },
|
||||
saveFailed: { zh: '保存账单关系失败', en: 'Failed to save the relation', ja: '関連の保存に失敗しました' },
|
||||
loadFailed: { zh: '加载账单关系失败', en: 'Failed to load relations', ja: '関連の読み込みに失敗しました' },
|
||||
deleteFailed: { zh: '删除账单关系失败', en: 'Failed to delete the relation', ja: '関連の削除に失敗しました' },
|
||||
},
|
||||
detail: {
|
||||
button: { zh: '详情', en: 'Details', ja: '詳細' },
|
||||
title: { zh: '账单详情', en: 'Bill Details', ja: '明細の詳細' },
|
||||
basicInfo: { zh: '基本信息', en: 'Basic Info', ja: '基本情報' },
|
||||
relations: { zh: '账单关系', en: 'Bill Relations', ja: '明細の関連' },
|
||||
images: { zh: '来源图片', en: 'Source Images', ja: '元の画像' },
|
||||
rawData: { zh: '原始数据', en: 'Raw Data', ja: '元データ' },
|
||||
createdAt: { zh: '创建时间', en: 'Created', ja: '作成日時' },
|
||||
noTags: { zh: '无标签', en: 'No tags', ja: 'タグなし' },
|
||||
noImages: { zh: '无来源图片', en: 'No source images', ja: '元の画像はありません' },
|
||||
noRawData: { zh: '无原始数据', en: 'No raw data', ja: '元データはありません' },
|
||||
},
|
||||
},
|
||||
|
||||
// 分类管理
|
||||
@@ -735,6 +780,11 @@ export default {
|
||||
needInfo: { zh: '请先填写备注或金额,便于生成标签', en: 'Please fill in a remark or amount first', ja: 'タグ生成のため、先に備考または金額を入力してください' },
|
||||
failed: { zh: 'AI 生成标签失败', en: 'Failed to generate tag', ja: 'AI によるタグ生成に失敗しました' },
|
||||
},
|
||||
relation: {
|
||||
button: { zh: 'AI 生成', en: 'AI Generate', ja: 'AI 生成' },
|
||||
needMembers: { zh: '请先选择至少两条关联账单', en: 'Please select at least two related bills first', ja: '関連する明細を2件以上選択してください' },
|
||||
failed: { zh: 'AI 未能生成关系名称,请重试', en: 'AI failed to generate a relation name', ja: 'AI が関連名を生成できませんでした' },
|
||||
},
|
||||
query: {
|
||||
placeholder: { zh: '用自然语言查询,如:上个月餐饮花了多少', en: 'Ask in natural language, e.g. dining expenses last month', ja: '自然言語で検索(例:先月の食費はいくら)' },
|
||||
examples: { zh: '上个月餐饮|超过500的支出|待报销的支出|微信支付', en: 'Dining last month|Expenses over 500|Pending reimbursement|Paid by WeChat', ja: '先月の食費|500を超える支出|精算待ちの支出|WeChat 払い' },
|
||||
@@ -830,7 +880,15 @@ export default {
|
||||
suggest: { zh: '生成建议', en: 'Suggest', ja: '提案生成' },
|
||||
decorate: { zh: '图标配色', en: 'Icon & Color', ja: 'アイコンと配色' },
|
||||
category: { zh: '生成分类', en: 'Category', ja: 'カテゴリ生成' },
|
||||
tag: { zh: '生成标签', en: 'Tag', ja: 'タグ生成' },
|
||||
channel: { zh: '生成渠道', en: 'Channel', ja: 'チャネル生成' },
|
||||
test: { zh: '测试连接', en: 'Test', ja: '接続テスト' },
|
||||
query: { zh: '智能查询', en: 'Query', ja: 'スマート検索' },
|
||||
record: { zh: '智能记账', en: 'Record', ja: 'スマート記帳' },
|
||||
record_image: { zh: '图片记账', en: 'Image Record', ja: '画像記帳' },
|
||||
rule: { zh: '生成规则', en: 'Rule', ja: 'ルール生成' },
|
||||
statistics: { zh: '统计搜索', en: 'Statistics', ja: '統計検索' },
|
||||
relation: { zh: '生成关系', en: 'Relation', ja: '関連生成' },
|
||||
},
|
||||
column: {
|
||||
time: { zh: '时间', en: 'Time', ja: '日時' },
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import { ref, onMounted, onBeforeUnmount, computed, h, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDivider, NDropdown, NUpload, NUploadDragger, NTabs, NTabPane, NSpin } from 'naive-ui'
|
||||
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDivider, NDropdown, NUpload, NUploadDragger, NTabs, NTabPane } from 'naive-ui'
|
||||
import { EyeOutline, EyeOffOutline, SparklesOutline, BulbOutline, RefreshOutline, CloudUploadOutline, CheckmarkCircleOutline } from '@vicons/ionicons5'
|
||||
import type { DataTableColumns, UploadFileInfo } from 'naive-ui'
|
||||
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags, getChannels, batchDeleteTransactions, batchCreateTransactions } from '../api/transaction'
|
||||
import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi, parseChannelByAi, getAiImageBlob } from '../api/ai'
|
||||
import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi, parseChannelByAi } from '../api/ai'
|
||||
import { createChannel } from '../api/channel'
|
||||
import type { AiSuggestion, AiQueryResult, AiRecordResult, AiRecordImageResult } from '../api/ai'
|
||||
import { getExcludedDisplayStyle } from '../api/setting'
|
||||
@@ -18,6 +18,10 @@ import { renderCategoryLabel } from '../utils/render'
|
||||
import { useKeywordHistory } from '../composables/useKeywordHistory'
|
||||
import { openSse } from '../utils/sse'
|
||||
import BillImportModal from './BillImportModal.vue'
|
||||
import TransactionDetailModal from '../components/TransactionDetailModal.vue'
|
||||
import TransactionRelationEditorModal from '../components/TransactionRelationEditorModal.vue'
|
||||
import TransactionRelationModal from '../components/TransactionRelationModal.vue'
|
||||
import type { RelationMemberDraft } from '../api/transactionGroup'
|
||||
|
||||
const message = useMessage() // 消息提示
|
||||
const dialog = useDialog() // 对话框
|
||||
@@ -41,6 +45,8 @@ interface TransactionItem {
|
||||
tags: TagOption[]
|
||||
/** 来源图片ID列表(图片记账生成时存在) */
|
||||
imageIds: number[]
|
||||
/** 所属关系组数量(父子/同级关系) */
|
||||
relationGroupCount: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -125,6 +131,12 @@ const excludedDisplayStyle = ref<'strikethrough' | 'muted'>('strikethrough')
|
||||
const submitting = ref(false) // 表单提交状态
|
||||
const showModal = ref(false) // 新增/编辑弹窗显示状态
|
||||
const showImportModal = ref(false) // 账单导入弹窗显示状态
|
||||
const showDetailModal = ref(false) // 账单详情弹窗显示状态
|
||||
const detailTransaction = ref<TransactionItem | null>(null) // 当前查看详情的账单
|
||||
const showRelationEditor = ref(false) // 勾选账单建立关系弹窗显示状态
|
||||
const relationEditorMembers = ref<RelationMemberDraft[]>([]) // 建立关系时的预填成员
|
||||
const showRelationModal = ref(false) // 关系查看弹窗显示状态
|
||||
const relationTransaction = ref<TransactionItem | null>(null) // 当前查看关系的账单
|
||||
const showBatchModal = ref(false) // 批量设置分类弹窗显示状态
|
||||
const showAiModal = ref(false) // AI 建议弹窗显示状态
|
||||
const aiEnabled = ref(false) // AI 建议是否启用
|
||||
@@ -226,18 +238,6 @@ const checkedAiRecordKeys = ref<number[]>([]) // 图片识别结果勾选的行
|
||||
const aiRecordImporting = ref(false) // 图片记账导入状态
|
||||
const aiRecordProgress = ref<{ time: string; text: string }[]>([]) // 图片识别进度日志(含接收时间)
|
||||
let aiRecordStreamController: AbortController | null = null // 图片识别 SSE 连接控制器
|
||||
const showImageViewModal = ref(false) // 来源图片查看弹窗显示状态
|
||||
|
||||
/** 来源图片查看项:默认加载缩略图,用户可按需切换为原图 */
|
||||
interface ImageViewItem {
|
||||
id: number // 图片ID
|
||||
url: string // 当前展示的 objectURL
|
||||
isOriginal: boolean // 是否已加载原图
|
||||
loading: boolean // 原图加载状态
|
||||
}
|
||||
|
||||
const imageViewItems = ref<ImageViewItem[]>([]) // 来源图片列表
|
||||
const imageViewLoading = ref(false) // 来源图片加载状态
|
||||
const showRawModal = ref(false) // 原始账单数据弹窗显示状态
|
||||
const rawDataText = ref('') // 格式化后的原始账单数据文本
|
||||
const batchExpenseCategoryId = ref<number | null>(null) // 批量设置的支出分类
|
||||
@@ -261,6 +261,7 @@ const categories = ref<CategoryOption[]>([]) // 分类选项列表
|
||||
const allTags = ref<TagOption[]>([]) // 全部标签(用于筛选、编辑、批量)
|
||||
const revealedRemarkIds = ref<number[]>([]) // 临时展开查看原文的备注行 ID(不持久,刷新/翻页后重置)
|
||||
const checkedTransactionIds = ref<number[]>([]) // 表格多选选中的记录ID(用于批量删除)
|
||||
const selectionMode = ref(false) // 批量选择模式:开启后才显示多选列
|
||||
|
||||
// 表单数据(新增/编辑共用)
|
||||
const form = ref({
|
||||
@@ -509,8 +510,8 @@ function createColumns(): DataTableColumns<TransactionItem> {
|
||||
// 读取展示样式,使列配置随样式偏好变化而重新计算
|
||||
const muted = excludedDisplayStyle.value === 'muted'
|
||||
return [
|
||||
// 多选列:用于批量操作(如批量删除)
|
||||
{ type: 'selection' },
|
||||
// 多选列:仅在批量选择模式下显示,默认不渲染复选框
|
||||
...(selectionMode.value ? [{ type: 'selection' as const }] : []),
|
||||
{
|
||||
// 序号列:按分页计算全局序号
|
||||
title: t('transaction.column.index'), key: 'index', width: 70, align: 'center',
|
||||
@@ -601,13 +602,13 @@ function createColumns(): DataTableColumns<TransactionItem> {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('transaction.sourceImages'), key: 'imageIds', width: 110, align: 'center',
|
||||
title: t('transaction.relation.column'), key: 'relationGroupCount', width: 110, align: 'center',
|
||||
render(row) {
|
||||
if (!row.imageIds || row.imageIds.length === 0) {
|
||||
return h('span', { style: { color: '#ccc' } }, '-')
|
||||
if (!row.relationGroupCount) {
|
||||
return h('span', { style: { color: '#ccc' } }, t('transaction.relation.none'))
|
||||
}
|
||||
return h(NButton, { size: 'small', quaternary: true, type: 'primary', onClick: () => openImageView(row.imageIds) },
|
||||
{ default: () => t('transaction.viewImages', { count: row.imageIds.length }) })
|
||||
return h(NButton, { size: 'small', quaternary: true, type: 'info', onClick: () => openRelation(row) },
|
||||
{ default: () => t('transaction.relation.viewCount', { count: row.relationGroupCount }) })
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -615,8 +616,8 @@ function createColumns(): DataTableColumns<TransactionItem> {
|
||||
render(row) {
|
||||
return h(NSpace, null, {
|
||||
default: () => [
|
||||
h(NButton, { size: 'small', onClick: () => openDetail(row) }, { default: () => t('transaction.detail.button') }),
|
||||
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }),
|
||||
h(NButton, { size: 'small', disabled: !row.rawData, onClick: () => handleViewRaw(row) }, { default: () => t('transaction.rawData') }),
|
||||
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => t('common.delete') }),
|
||||
],
|
||||
})
|
||||
@@ -711,18 +712,49 @@ watch(sortValue, () => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
/** 查看某条记录的原始账单数据(格式化为缩进 JSON) */
|
||||
function handleViewRaw(row: TransactionItem) {
|
||||
if (!row.rawData) {
|
||||
message.warning(t('transaction.noRawData'))
|
||||
/** 打开账单详情弹窗 */
|
||||
function openDetail(row: TransactionItem) {
|
||||
detailTransaction.value = row
|
||||
showDetailModal.value = true
|
||||
}
|
||||
|
||||
/** 打开账单关系弹窗 */
|
||||
function openRelation(row: TransactionItem) {
|
||||
relationTransaction.value = row
|
||||
showRelationModal.value = true
|
||||
}
|
||||
|
||||
/** 从详情弹窗进入编辑 */
|
||||
function handleDetailEdit() {
|
||||
if (detailTransaction.value) {
|
||||
showDetailModal.value = false
|
||||
handleEdit(detailTransaction.value)
|
||||
}
|
||||
}
|
||||
|
||||
/** 以表格勾选的账单为成员,打开建立关系弹窗 */
|
||||
function handleCreateRelation() {
|
||||
if (checkedTransactionIds.value.length < 2) {
|
||||
message.warning(t('transaction.relation.selectAtLeastTwo'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
rawDataText.value = JSON.stringify(JSON.parse(row.rawData), null, 2)
|
||||
} catch (e) {
|
||||
rawDataText.value = row.rawData
|
||||
const selected = transactions.value.filter((row) => checkedTransactionIds.value.includes(row.id))
|
||||
relationEditorMembers.value = selected.map((row) => ({
|
||||
transactionId: row.id,
|
||||
type: row.type,
|
||||
amount: row.amount,
|
||||
channel: row.channel,
|
||||
remark: row.remark,
|
||||
transactionTime: row.transactionTime,
|
||||
categoryName: row.categoryName,
|
||||
categoryIcon: row.categoryIcon,
|
||||
}))
|
||||
showRelationEditor.value = true
|
||||
}
|
||||
showRawModal.value = true
|
||||
|
||||
/** 建立关系保存成功后刷新列表(选择随刷新清空) */
|
||||
function onRelationEditorSaved() {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
/** 查看图片记账结果行的原始数据(格式化为缩进 JSON) */
|
||||
@@ -736,57 +768,6 @@ function handleViewAiRecordRaw(row: AiRecordEditRow) {
|
||||
showRawModal.value = true
|
||||
}
|
||||
|
||||
/** 从接口响应中提取图片 Blob(blob 响应经拦截器后返回完整 axios response,需取其 data) */
|
||||
function extractBlob(res: any): Blob {
|
||||
return res?.data instanceof Blob ? res.data : (res instanceof Blob ? res : new Blob([res]))
|
||||
}
|
||||
|
||||
/** 查看交易记录的来源图片(默认加载缩略图,避免大图拖慢打开速度) */
|
||||
async function openImageView(imageIds: number[]) {
|
||||
if (!imageIds || imageIds.length === 0) return
|
||||
closeImageView()
|
||||
showImageViewModal.value = true
|
||||
imageViewLoading.value = true
|
||||
try {
|
||||
const items: ImageViewItem[] = []
|
||||
for (const id of imageIds) {
|
||||
const res: any = await getAiImageBlob(id, true)
|
||||
items.push({ id, url: URL.createObjectURL(extractBlob(res)), isOriginal: false, loading: false })
|
||||
}
|
||||
imageViewItems.value = items
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('common.loadFailed'))
|
||||
} finally {
|
||||
imageViewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 将指定来源图片切换为原图(替换并释放旧缩略图 objectURL) */
|
||||
async function loadOriginalImage(item: ImageViewItem) {
|
||||
if (item.isOriginal || item.loading) return
|
||||
item.loading = true
|
||||
try {
|
||||
const res: any = await getAiImageBlob(item.id)
|
||||
const url = URL.createObjectURL(extractBlob(res))
|
||||
URL.revokeObjectURL(item.url)
|
||||
item.url = url
|
||||
item.isOriginal = true
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('common.loadFailed'))
|
||||
} finally {
|
||||
item.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭来源图片弹窗并释放 objectURL */
|
||||
function closeImageView() {
|
||||
for (const item of imageViewItems.value) {
|
||||
URL.revokeObjectURL(item.url)
|
||||
}
|
||||
imageViewItems.value = []
|
||||
showImageViewModal.value = false
|
||||
}
|
||||
|
||||
/** 打开新增记录弹窗,重置表单 */
|
||||
function handleAdd() {
|
||||
editingId.value = null
|
||||
@@ -899,6 +880,17 @@ function handleBatchDelete() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 进入批量选择模式(显示多选列) */
|
||||
function enterSelectionMode() {
|
||||
selectionMode.value = true
|
||||
}
|
||||
|
||||
/** 退出批量选择模式并清空已选 */
|
||||
function exitSelectionMode() {
|
||||
selectionMode.value = false
|
||||
checkedTransactionIds.value = []
|
||||
}
|
||||
|
||||
/** 翻页事件处理 */
|
||||
function handlePageChange(p: number) {
|
||||
page.value = p
|
||||
@@ -2062,10 +2054,15 @@ onBeforeUnmount(() => {
|
||||
<span style="color: #999; font-size: 13px;">{{ t('transaction.sortLabel') }}</span>
|
||||
<NSelect v-model:value="sortValue" :options="sortOptions" style="width: 170px;" />
|
||||
<NDivider vertical style="margin: 0 4px;" />
|
||||
<NButton v-if="!selectionMode" @click="enterSelectionMode">{{ t('transaction.batchMode') }}</NButton>
|
||||
<NButton v-else @click="exitSelectionMode">{{ t('transaction.exitBatchMode') }}</NButton>
|
||||
<NButton @click="handleBatchOpen">{{ t('transaction.batchSet') }}</NButton>
|
||||
<NButton type="error" :disabled="checkedTransactionIds.length === 0" @click="handleBatchDelete">
|
||||
<NButton v-if="checkedTransactionIds.length > 0" type="error" @click="handleBatchDelete">
|
||||
{{ t('transaction.batchDelete') }}
|
||||
</NButton>
|
||||
<NButton v-if="checkedTransactionIds.length >= 2" type="primary" ghost @click="handleCreateRelation">
|
||||
{{ t('transaction.relation.createFromSelection') }}
|
||||
</NButton>
|
||||
<NButton @click="showImportModal = true">{{ t('transaction.importBill') }}</NButton>
|
||||
<NButton @click="goChannelManage">{{ t('channel.manageEntry') }}</NButton>
|
||||
<NButton :loading="exporting" @click="handleExport">{{ t('transaction.exportExcel') }}</NButton>
|
||||
@@ -2342,28 +2339,35 @@ onBeforeUnmount(() => {
|
||||
<!-- 账单导入弹窗 -->
|
||||
<BillImportModal v-model:show="showImportModal" @imported="fetchData" />
|
||||
|
||||
<!-- 账单详情弹窗(含关系与来源图片) -->
|
||||
<TransactionDetailModal
|
||||
v-model:show="showDetailModal"
|
||||
:transaction="detailTransaction"
|
||||
@changed="fetchData"
|
||||
@edit="handleDetailEdit"
|
||||
/>
|
||||
|
||||
<!-- 勾选账单建立关系弹窗 -->
|
||||
<TransactionRelationEditorModal
|
||||
v-model:show="showRelationEditor"
|
||||
:editing-group="null"
|
||||
:initial-members="relationEditorMembers"
|
||||
:anchor-transaction-id="null"
|
||||
@saved="onRelationEditorSaved"
|
||||
/>
|
||||
|
||||
<!-- 关系查看弹窗 -->
|
||||
<TransactionRelationModal
|
||||
v-model:show="showRelationModal"
|
||||
:transaction="relationTransaction"
|
||||
@changed="fetchData"
|
||||
/>
|
||||
|
||||
<!-- 原始账单数据弹窗 -->
|
||||
<NModal v-model:show="showRawModal" preset="card" :title="t('transaction.rawDataTitle')" style="width: 640px;">
|
||||
<NInput :value="rawDataText" type="textarea" readonly :autosize="{ minRows: 8, maxRows: 20 }" />
|
||||
</NModal>
|
||||
|
||||
<!-- 来源图片查看弹窗(图片记账生成的记录):默认加载缩略图,可切换原图 -->
|
||||
<NModal v-model:show="showImageViewModal" preset="card" :title="t('transaction.sourceImages')" style="width: 720px; max-width: 92vw;" @after-leave="closeImageView">
|
||||
<NSpin :show="imageViewLoading">
|
||||
<NSpace vertical :size="12" style="min-height: 60px;">
|
||||
<div v-for="item in imageViewItems" :key="item.id" class="image-view-item">
|
||||
<img :src="item.url" alt="source image" style="width: 100%; max-height: 70vh; object-fit: contain; border-radius: 6px;" />
|
||||
<div class="image-view-actions">
|
||||
<NTag v-if="item.isOriginal" size="small" type="success">{{ t('transaction.originalImage') }}</NTag>
|
||||
<NButton v-else size="small" :loading="item.loading" @click="loadOriginalImage(item)">
|
||||
{{ t('transaction.loadOriginal') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</NSpace>
|
||||
</NSpin>
|
||||
</NModal>
|
||||
|
||||
<!-- 一键设置弹窗(作用于当前表格展示的记录) -->
|
||||
<NModal v-model:show="showBatchModal" preset="card" :title="t('transaction.batchTitle')" style="width: 480px;">
|
||||
<NForm>
|
||||
@@ -2602,15 +2606,6 @@ onBeforeUnmount(() => {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
/* 图片识别进度:浅色卡片 + 步骤列表 */
|
||||
.image-view-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.image-view-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.ai-progress {
|
||||
background: #f7f9fc;
|
||||
border: 1px solid #eef1f6;
|
||||
|
||||
@@ -28,6 +28,7 @@ import cn.rosercode.finance.dto.AiConfigRequest;
|
||||
import cn.rosercode.finance.dto.AiDecorateRequest;
|
||||
import cn.rosercode.finance.dto.AiQueryRequest;
|
||||
import cn.rosercode.finance.dto.AiRecordRequest;
|
||||
import cn.rosercode.finance.dto.AiRelationRequest;
|
||||
import cn.rosercode.finance.dto.AiRuleApplyRequest;
|
||||
import cn.rosercode.finance.dto.AiRuleSuggestRequest;
|
||||
import cn.rosercode.finance.dto.AiSuggestOneRequest;
|
||||
@@ -44,6 +45,7 @@ import cn.rosercode.finance.service.AiImageRecordStreamService;
|
||||
import cn.rosercode.finance.service.AiImageStorage;
|
||||
import cn.rosercode.finance.service.AiQueryService;
|
||||
import cn.rosercode.finance.service.AiRecordService;
|
||||
import cn.rosercode.finance.service.AiRelationService;
|
||||
import cn.rosercode.finance.service.AiRuleService;
|
||||
import cn.rosercode.finance.service.AiStatisticsService;
|
||||
import cn.rosercode.finance.service.AiSuggestionService;
|
||||
@@ -57,6 +59,7 @@ import cn.rosercode.finance.vo.AiConfigVO;
|
||||
import cn.rosercode.finance.vo.AiQueryResultVO;
|
||||
import cn.rosercode.finance.vo.AiRecordImageResultVO;
|
||||
import cn.rosercode.finance.vo.AiRecordResultVO;
|
||||
import cn.rosercode.finance.vo.AiRelationResultVO;
|
||||
import cn.rosercode.finance.vo.AiRuleSuggestResultVO;
|
||||
import cn.rosercode.finance.vo.AiStatisticsResultVO;
|
||||
import cn.rosercode.finance.vo.AiSuggestionVO;
|
||||
@@ -91,6 +94,7 @@ public class AiController {
|
||||
private final AiStatisticsService aiStatisticsService;
|
||||
private final AiImageRecordService aiImageRecordService;
|
||||
private final AiImageRecordStreamService aiImageRecordStreamService;
|
||||
private final AiRelationService aiRelationService;
|
||||
private final AiImageMapper aiImageMapper;
|
||||
private final AiImageStorage aiImageStorage;
|
||||
|
||||
@@ -102,6 +106,7 @@ public class AiController {
|
||||
AiRuleService aiRuleService, AiStatisticsService aiStatisticsService,
|
||||
AiImageRecordService aiImageRecordService,
|
||||
AiImageRecordStreamService aiImageRecordStreamService,
|
||||
AiRelationService aiRelationService,
|
||||
AiImageMapper aiImageMapper, AiImageStorage aiImageStorage) {
|
||||
this.aiConfigService = aiConfigService;
|
||||
this.aiSuggestionService = aiSuggestionService;
|
||||
@@ -116,6 +121,7 @@ public class AiController {
|
||||
this.aiStatisticsService = aiStatisticsService;
|
||||
this.aiImageRecordService = aiImageRecordService;
|
||||
this.aiImageRecordStreamService = aiImageRecordStreamService;
|
||||
this.aiRelationService = aiRelationService;
|
||||
this.aiImageMapper = aiImageMapper;
|
||||
this.aiImageStorage = aiImageStorage;
|
||||
}
|
||||
@@ -360,6 +366,19 @@ public class AiController {
|
||||
return Result.success(aiChannelService.parse(userId, channelRequest.getText()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据关系类型与成员账单特征生成关系名称与备注
|
||||
*
|
||||
* @param relationRequest 生成请求,携带关系类型与成员账单特征
|
||||
* @return 关系名称与备注建议
|
||||
*/
|
||||
@PostMapping("/relation")
|
||||
@Operation(summary = "AI 生成账单关系", description = "根据关系类型与成员账单特征生成关系名称与备注")
|
||||
public Result<AiRelationResultVO> generateRelation(@RequestBody @Valid AiRelationRequest relationRequest) {
|
||||
Long userId = UserContext.getUserId();
|
||||
return Result.success(aiRelationService.parse(userId, relationRequest));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分类名称推荐 Emoji 图标
|
||||
*
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package cn.rosercode.finance.controller;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import cn.rosercode.finance.common.Result;
|
||||
import cn.rosercode.finance.dto.TransactionGroupRequest;
|
||||
import cn.rosercode.finance.service.TransactionGroupService;
|
||||
import cn.rosercode.finance.util.UserContext;
|
||||
import cn.rosercode.finance.vo.TransactionGroupVO;
|
||||
|
||||
/**
|
||||
* 账单关系组控制器
|
||||
*
|
||||
* <p>提供账单之间父子/同级关系的查询、创建、更新与删除接口</p>
|
||||
*
|
||||
* @date 2026-09-21 20:32:41
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/transaction/group")
|
||||
@Tag(name = "账单关系管理", description = "账单之间父子/同级关系的查询与维护接口")
|
||||
public class TransactionGroupController {
|
||||
|
||||
private final TransactionGroupService transactionGroupService;
|
||||
|
||||
public TransactionGroupController(TransactionGroupService transactionGroupService) {
|
||||
this.transactionGroupService = transactionGroupService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询某笔账单所属的全部关系组
|
||||
*
|
||||
* @param transactionId 交易记录ID
|
||||
* @return 关系组列表
|
||||
*/
|
||||
@GetMapping("/by-transaction/{transactionId}")
|
||||
@Operation(summary = "查询账单关系", description = "返回指定账单所属的父子/同级关系组及成员账单")
|
||||
public Result<List<TransactionGroupVO>> listByTransaction(
|
||||
@Parameter(description = "交易记录ID") @PathVariable Long transactionId) {
|
||||
return Result.success(transactionGroupService.listByTransaction(UserContext.getUserId(), transactionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户使用过的关系名称历史
|
||||
*
|
||||
* @param type 关系类型过滤,可选
|
||||
* @return 关系名称列表
|
||||
*/
|
||||
@GetMapping("/names")
|
||||
@Operation(summary = "查询关系名称历史", description = "返回当前用户使用过的账单关系名称,按最近使用排序,可按关系类型过滤")
|
||||
public Result<List<String>> names(
|
||||
@Parameter(description = "关系类型过滤,可选") @RequestParam(value = "type", required = false) String type) {
|
||||
return Result.success(transactionGroupService.listNames(UserContext.getUserId(), type));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建关系组
|
||||
*
|
||||
* @param request 关系组请求参数
|
||||
* @return 新建关系组ID
|
||||
*/
|
||||
@PostMapping
|
||||
@Operation(summary = "创建账单关系", description = "将多笔账单按父子或同级关系聚合为一个关系组")
|
||||
public Result<Long> create(@RequestBody @Valid TransactionGroupRequest request) {
|
||||
return Result.success(transactionGroupService.create(UserContext.getUserId(), request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新关系组
|
||||
*
|
||||
* @param id 关系组ID
|
||||
* @param request 关系组请求参数
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "更新账单关系", description = "更新关系组的名称、备注、成员与角色")
|
||||
public Result<Void> update(@Parameter(description = "关系组ID") @PathVariable Long id,
|
||||
@RequestBody @Valid TransactionGroupRequest request) {
|
||||
transactionGroupService.update(UserContext.getUserId(), id, request);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除关系组
|
||||
*
|
||||
* @param id 关系组ID
|
||||
* @return 操作结果
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "删除账单关系", description = "删除关系组,仅解除账单关联,不删除账单")
|
||||
public Result<Void> delete(@Parameter(description = "关系组ID") @PathVariable Long id) {
|
||||
transactionGroupService.delete(UserContext.getUserId(), id);
|
||||
return Result.success();
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import cn.rosercode.finance.entity.NotificationLog;
|
||||
import cn.rosercode.finance.entity.Rule;
|
||||
import cn.rosercode.finance.entity.Tag;
|
||||
import cn.rosercode.finance.entity.Transaction;
|
||||
import cn.rosercode.finance.entity.TransactionGroup;
|
||||
import cn.rosercode.finance.entity.TransactionGroupItem;
|
||||
import cn.rosercode.finance.entity.User;
|
||||
import cn.rosercode.finance.vo.AiUsageVO;
|
||||
import cn.rosercode.finance.vo.CategoryVO;
|
||||
@@ -30,6 +32,8 @@ import cn.rosercode.finance.vo.NotificationChannelVO;
|
||||
import cn.rosercode.finance.vo.NotificationLogVO;
|
||||
import cn.rosercode.finance.vo.RuleVO;
|
||||
import cn.rosercode.finance.vo.TagVO;
|
||||
import cn.rosercode.finance.vo.TransactionGroupItemVO;
|
||||
import cn.rosercode.finance.vo.TransactionGroupVO;
|
||||
import cn.rosercode.finance.vo.TransactionVO;
|
||||
import cn.rosercode.finance.vo.UserVO;
|
||||
|
||||
@@ -129,6 +133,27 @@ public interface VoConverter {
|
||||
@Mapping(target = "tags", ignore = true)
|
||||
TransactionVO toTransactionVO(Transaction transaction, Category category);
|
||||
|
||||
/**
|
||||
* 账单关系组实体转视图对象。
|
||||
*
|
||||
* <p>成员列表由调用方另行填充。</p>
|
||||
*
|
||||
* @param group 关系组实体
|
||||
* @return 关系组视图对象
|
||||
*/
|
||||
@Mapping(target = "items", ignore = true)
|
||||
TransactionGroupVO toTransactionGroupVO(TransactionGroup group);
|
||||
|
||||
/**
|
||||
* 账单关系组成员实体转视图对象。
|
||||
*
|
||||
* <p>仅映射成员角色与排序,关联账单的展示快照由调用方另行填充。</p>
|
||||
*
|
||||
* @param item 关系组成员实体
|
||||
* @return 关系组成员视图对象
|
||||
*/
|
||||
TransactionGroupItemVO toTransactionGroupItemVO(TransactionGroupItem item);
|
||||
|
||||
/**
|
||||
* 通知渠道实体转视图对象。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package cn.rosercode.finance.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* AI 生成账单关系请求的成员账单特征 DTO。
|
||||
*
|
||||
* <p>仅承载收支类型、交易渠道、分类名称与备注,禁止携带金额、交易号、原始数据等敏感字段。</p>
|
||||
*
|
||||
* @date 2026-09-21 21:08:21
|
||||
*/
|
||||
@Data
|
||||
public class AiRelationMemberRequest {
|
||||
|
||||
/** 收支类型:INCOME-收入,EXPENSE-支出 */
|
||||
private String type;
|
||||
|
||||
/** 交易渠道 */
|
||||
private String channel;
|
||||
|
||||
/** 分类名称 */
|
||||
private String categoryName;
|
||||
|
||||
/** 备注 */
|
||||
private String remark;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package cn.rosercode.finance.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* AI 生成账单关系名称与备注请求 DTO,携带关系类型与成员账单特征。
|
||||
*
|
||||
* @date 2026-09-21 21:08:21
|
||||
*/
|
||||
@Data
|
||||
public class AiRelationRequest {
|
||||
|
||||
/** 关系类型:PARENT_CHILD-父子,SIBLING-同级 */
|
||||
@NotBlank(message = "关系类型不能为空")
|
||||
private String type;
|
||||
|
||||
/** 成员账单特征列表,至少两条 */
|
||||
@NotEmpty(message = "请先选择关联账单")
|
||||
@Valid
|
||||
private List<AiRelationMemberRequest> members;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package cn.rosercode.finance.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 账单关系组成员请求DTO,用于指定关系组中的一笔账单及其角色。
|
||||
*
|
||||
* @date 2026-09-21 20:32:41
|
||||
*/
|
||||
@Data
|
||||
public class TransactionGroupItemRequest {
|
||||
|
||||
/** 交易记录ID,不能为空 */
|
||||
@NotNull(message = "关联账单不能为空")
|
||||
private Long transactionId;
|
||||
|
||||
/** 组内角色:PARENT-父,CHILD-子,MEMBER-成员 */
|
||||
private String role;
|
||||
|
||||
/** 排序序号,用于同级链路顺序 */
|
||||
private Integer sortOrder;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package cn.rosercode.finance.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 账单关系组请求DTO,用于创建或更新一个关系组及其成员。
|
||||
*
|
||||
* @date 2026-09-21 20:32:41
|
||||
*/
|
||||
@Data
|
||||
public class TransactionGroupRequest {
|
||||
|
||||
/** 关系类型:PARENT_CHILD-父子,SIBLING-同级 */
|
||||
@NotBlank(message = "关系类型不能为空")
|
||||
private String type;
|
||||
|
||||
/** 关系组名称,可选 */
|
||||
@Size(max = 100, message = "关系组名称不能超过100个字符")
|
||||
private String name;
|
||||
|
||||
/** 备注,可选 */
|
||||
@Size(max = 255, message = "备注不能超过255个字符")
|
||||
private String remark;
|
||||
|
||||
/** 关系组成员列表,至少两条 */
|
||||
@NotEmpty(message = "关系组至少包含两条账单")
|
||||
@Valid
|
||||
private List<TransactionGroupItemRequest> items;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package cn.rosercode.finance.entity;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 账单关系组实体类,对应数据库表 t_transaction_group,将多笔账单按父子或同级关系聚合。
|
||||
*
|
||||
* @date 2026-09-21 20:32:41
|
||||
*/
|
||||
@Data
|
||||
@TableName("t_transaction_group")
|
||||
public class TransactionGroup {
|
||||
|
||||
/** 关系组ID,主键自增 */
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** 更新时间 */
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/** 所属用户ID */
|
||||
private Long userId;
|
||||
|
||||
/** 关系类型:PARENT_CHILD-父子,SIBLING-同级 */
|
||||
private String type;
|
||||
|
||||
/** 关系组名称 */
|
||||
private String name;
|
||||
|
||||
/** 备注 */
|
||||
private String remark;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package cn.rosercode.finance.entity;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 账单关系组成员实体类,对应数据库表 t_transaction_group_item,维护关系组与账单的关联及组内角色。
|
||||
*
|
||||
* @date 2026-09-21 20:32:41
|
||||
*/
|
||||
@Data
|
||||
@TableName("t_transaction_group_item")
|
||||
public class TransactionGroupItem {
|
||||
|
||||
/** 关联ID,主键自增 */
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** 关系组ID */
|
||||
private Long groupId;
|
||||
|
||||
/** 交易记录ID */
|
||||
private Long transactionId;
|
||||
|
||||
/** 组内角色:PARENT-父,CHILD-子,MEMBER-成员 */
|
||||
private String role;
|
||||
|
||||
/** 排序序号,用于同级链路顺序与展示顺序 */
|
||||
private Integer sortOrder;
|
||||
}
|
||||
@@ -38,5 +38,8 @@ public enum AiCallType {
|
||||
RULE,
|
||||
|
||||
/** 自然语言统计查询解析 */
|
||||
STATISTICS
|
||||
STATISTICS,
|
||||
|
||||
/** 生成账单关系名称与备注 */
|
||||
RELATION
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package cn.rosercode.finance.enums;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 账单关系组内角色枚举,统一维护成员在关系组中的角色编码与中文名称。
|
||||
*
|
||||
* @date 2026-09-21 20:32:41
|
||||
*/
|
||||
public enum TransactionGroupRole {
|
||||
|
||||
/** 父账单:父子关系中作为资金池的一方(如充值) */
|
||||
PARENT("父账单"),
|
||||
|
||||
/** 子账单:父子关系中从资金池消费的一方 */
|
||||
CHILD("子账单"),
|
||||
|
||||
/** 成员:同级关系中地位平等的账单 */
|
||||
MEMBER("成员");
|
||||
|
||||
/** 角色中文名称 */
|
||||
private final String label;
|
||||
|
||||
TransactionGroupRole(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取角色中文名称。
|
||||
*
|
||||
* @return 中文名称
|
||||
*/
|
||||
public String getLabel() {
|
||||
return label;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全部合法角色编码列表,顺序与枚举声明一致。
|
||||
*
|
||||
* @return 角色编码列表
|
||||
*/
|
||||
public static List<String> codes() {
|
||||
return Arrays.stream(values()).map(Enum::name).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为合法角色编码。
|
||||
*
|
||||
* @param code 角色编码
|
||||
* @return 合法返回 true
|
||||
*/
|
||||
public static boolean isValid(String code) {
|
||||
return code != null && Arrays.stream(values()).anyMatch(role -> role.name().equals(code));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package cn.rosercode.finance.enums;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 账单关系类型枚举,统一维护关系组的类型编码与中文名称。
|
||||
*
|
||||
* @date 2026-09-21 20:32:41
|
||||
*/
|
||||
public enum TransactionRelationType {
|
||||
|
||||
/** 父子关系:一个父账单(如充值)对应多个子账单(如消费) */
|
||||
PARENT_CHILD("父子关系"),
|
||||
|
||||
/** 同级关系:多笔账单地位平等(如支付链路上的多条记录) */
|
||||
SIBLING("同级关系");
|
||||
|
||||
/** 类型中文名称 */
|
||||
private final String label;
|
||||
|
||||
TransactionRelationType(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取类型中文名称。
|
||||
*
|
||||
* @return 中文名称
|
||||
*/
|
||||
public String getLabel() {
|
||||
return label;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全部合法类型编码列表,顺序与枚举声明一致。
|
||||
*
|
||||
* @return 类型编码列表
|
||||
*/
|
||||
public static List<String> codes() {
|
||||
return Arrays.stream(values()).map(Enum::name).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为合法类型编码。
|
||||
*
|
||||
* @param code 类型编码
|
||||
* @return 合法返回 true
|
||||
*/
|
||||
public static boolean isValid(String code) {
|
||||
return code != null && Arrays.stream(values()).anyMatch(type -> type.name().equals(code));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将类型编码转换为中文名称,未知编码原样返回。
|
||||
*
|
||||
* @param code 类型编码
|
||||
* @return 中文名称
|
||||
*/
|
||||
public static String labelOf(String code) {
|
||||
if (code == null) {
|
||||
return "";
|
||||
}
|
||||
for (TransactionRelationType type : values()) {
|
||||
if (type.name().equals(code)) {
|
||||
return type.label;
|
||||
}
|
||||
}
|
||||
return code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package cn.rosercode.finance.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import cn.rosercode.finance.entity.TransactionGroupItem;
|
||||
|
||||
/**
|
||||
* 账单关系组成员Mapper接口,继承MyBatis-Plus的BaseMapper,提供关系组成员表的基础CRUD操作。
|
||||
*
|
||||
* @date 2026-09-21 20:32:41
|
||||
*/
|
||||
@Mapper
|
||||
public interface TransactionGroupItemMapper extends BaseMapper<TransactionGroupItem> {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package cn.rosercode.finance.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import cn.rosercode.finance.entity.TransactionGroup;
|
||||
|
||||
/**
|
||||
* 账单关系组Mapper接口,继承MyBatis-Plus的BaseMapper,提供关系组的基础CRUD操作。
|
||||
*
|
||||
* @date 2026-09-21 20:32:41
|
||||
*/
|
||||
@Mapper
|
||||
public interface TransactionGroupMapper extends BaseMapper<TransactionGroup> {
|
||||
}
|
||||
@@ -152,6 +152,15 @@ public class PromptTemplateService {
|
||||
return render("ai-channel-system", null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染生成账单关系名称与备注的系统提示词(模板 {@code ai-relation-system})。
|
||||
*
|
||||
* @return 系统提示词
|
||||
*/
|
||||
public String relationSystemPrompt() {
|
||||
return render("ai-relation-system", null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Emoji 图标建议的系统提示词(模板 {@code ai-decoration-emoji-system})。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package cn.rosercode.finance.service;
|
||||
|
||||
import cn.rosercode.finance.dto.AiRelationRequest;
|
||||
import cn.rosercode.finance.vo.AiRelationResultVO;
|
||||
|
||||
/**
|
||||
* AI 生成账单关系服务接口,根据关系类型与成员账单特征生成关系名称与备注。
|
||||
*
|
||||
* @date 2026-09-21 21:08:21
|
||||
*/
|
||||
public interface AiRelationService {
|
||||
|
||||
/**
|
||||
* 根据关系类型与成员账单特征生成关系名称与备注。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param request 关系类型与成员账单特征
|
||||
* @return 关系名称与备注建议
|
||||
*/
|
||||
AiRelationResultVO parse(Long userId, AiRelationRequest request);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package cn.rosercode.finance.service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import cn.rosercode.finance.dto.TransactionGroupRequest;
|
||||
import cn.rosercode.finance.vo.TransactionGroupVO;
|
||||
|
||||
/**
|
||||
* 账单关系组服务接口,提供关系组的查询、创建、更新、删除与删除账单时的级联清理。
|
||||
*
|
||||
* @date 2026-09-21 20:32:41
|
||||
*/
|
||||
public interface TransactionGroupService {
|
||||
|
||||
/**
|
||||
* 查询某笔账单所属的全部关系组。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param transactionId 交易记录ID
|
||||
* @return 关系组列表(含成员账单快照)
|
||||
*/
|
||||
List<TransactionGroupVO> listByTransaction(Long userId, Long transactionId);
|
||||
|
||||
/**
|
||||
* 查询当前用户使用过的关系名称历史。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param type 关系类型过滤,为空时返回全部类型
|
||||
* @return 关系名称列表(按最近使用排序,去重)
|
||||
*/
|
||||
List<String> listNames(Long userId, String type);
|
||||
|
||||
/**
|
||||
* 创建关系组。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param request 关系组请求参数
|
||||
* @return 新建关系组ID
|
||||
*/
|
||||
Long create(Long userId, TransactionGroupRequest request);
|
||||
|
||||
/**
|
||||
* 更新关系组(名称、备注、成员与角色全量替换)。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param id 关系组ID
|
||||
* @param request 关系组请求参数
|
||||
*/
|
||||
void update(Long userId, Long id, TransactionGroupRequest request);
|
||||
|
||||
/**
|
||||
* 删除关系组(仅解除账单关联,不删除账单)。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param id 关系组ID
|
||||
*/
|
||||
void delete(Long userId, Long id);
|
||||
|
||||
/**
|
||||
* 删除账单后清理其关系关联:移除成员,组内不足或父子组失去父账单时删除整个关系组。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param transactionIds 被删除的交易记录ID列表
|
||||
*/
|
||||
void removeTransactionFromGroups(Long userId, List<Long> transactionIds);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package cn.rosercode.finance.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import cn.rosercode.finance.client.DeepSeekClient;
|
||||
import cn.rosercode.finance.client.DeepSeekResponse;
|
||||
import cn.rosercode.finance.common.BusinessException;
|
||||
import cn.rosercode.finance.dto.AiRelationMemberRequest;
|
||||
import cn.rosercode.finance.dto.AiRelationRequest;
|
||||
import cn.rosercode.finance.entity.AiConfig;
|
||||
import cn.rosercode.finance.enums.AiCallType;
|
||||
import cn.rosercode.finance.enums.TransactionRelationType;
|
||||
import cn.rosercode.finance.enums.TransactionType;
|
||||
import cn.rosercode.finance.prompt.PromptTemplateService;
|
||||
import cn.rosercode.finance.service.AiConfigService;
|
||||
import cn.rosercode.finance.service.AiRelationService;
|
||||
import cn.rosercode.finance.service.AiUsageService;
|
||||
import cn.rosercode.finance.vo.AiRelationResultVO;
|
||||
|
||||
/**
|
||||
* AI 生成账单关系服务实现类,根据关系类型与成员账单特征生成关系名称与备注。
|
||||
*
|
||||
* <p>用户提示词在服务端集中构造,仅包含收支类型、交易渠道、分类名称与截断后的备注,
|
||||
* 不发送金额、交易号、原始数据等敏感字段。</p>
|
||||
*
|
||||
* @date 2026-09-21 21:08:21
|
||||
*/
|
||||
@Service
|
||||
public class AiRelationServiceImpl implements AiRelationService {
|
||||
|
||||
/** 关系名称最大长度 */
|
||||
private static final int NAME_MAX_LENGTH = 50;
|
||||
|
||||
/** 关系备注最大长度 */
|
||||
private static final int REMARK_MAX_LENGTH = 200;
|
||||
|
||||
/** 单次最多发送的成员账单数量 */
|
||||
private static final int MEMBER_LIMIT = 20;
|
||||
|
||||
/** 单条备注截断长度 */
|
||||
private static final int REMARK_TRUNCATE = 30;
|
||||
|
||||
private final AiConfigService aiConfigService;
|
||||
private final DeepSeekClient deepSeekClient;
|
||||
private final AiUsageService aiUsageService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final PromptTemplateService promptTemplateService;
|
||||
|
||||
public AiRelationServiceImpl(AiConfigService aiConfigService,
|
||||
DeepSeekClient deepSeekClient,
|
||||
AiUsageService aiUsageService,
|
||||
ObjectMapper objectMapper,
|
||||
PromptTemplateService promptTemplateService) {
|
||||
this.aiConfigService = aiConfigService;
|
||||
this.deepSeekClient = deepSeekClient;
|
||||
this.aiUsageService = aiUsageService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.promptTemplateService = promptTemplateService;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public AiRelationResultVO parse(Long userId, AiRelationRequest request) {
|
||||
if (request == null || request.getMembers() == null || request.getMembers().isEmpty()) {
|
||||
throw new BusinessException("请先选择关联账单");
|
||||
}
|
||||
AiConfig config = requireConfig(userId);
|
||||
String userPrompt = buildUserPrompt(request);
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
DeepSeekResponse aiResponse;
|
||||
try {
|
||||
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
|
||||
promptTemplateService.relationSystemPrompt(), userPrompt);
|
||||
} catch (RuntimeException e) {
|
||||
// 失败也记录一次使用,便于排查问题
|
||||
aiUsageService.recordFailure(userId, AiCallType.RELATION, config.getModel(),
|
||||
request.getMembers().size(), System.currentTimeMillis() - start, e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
aiUsageService.recordSuccess(userId, AiCallType.RELATION, config.getModel(),
|
||||
request.getMembers().size(), aiResponse, System.currentTimeMillis() - start);
|
||||
|
||||
return parseResult(aiResponse.getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造用户提示词,仅包含白名单字段。
|
||||
*
|
||||
* @param request 请求参数
|
||||
* @return 用户提示词
|
||||
*/
|
||||
private String buildUserPrompt(AiRelationRequest request) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("关系类型:").append(TransactionRelationType.labelOf(request.getType())).append('\n');
|
||||
builder.append("成员账单:").append('\n');
|
||||
int index = 1;
|
||||
for (AiRelationMemberRequest member : request.getMembers()) {
|
||||
if (member == null) {
|
||||
continue;
|
||||
}
|
||||
if (index > MEMBER_LIMIT) {
|
||||
break;
|
||||
}
|
||||
builder.append(index).append(". ")
|
||||
.append(TransactionType.labelOf(member.getType())).append(" | ")
|
||||
.append(trim(member.getChannel())).append(" | ")
|
||||
.append(trim(member.getCategoryName())).append(" | ")
|
||||
.append(truncate(member.getRemark(), REMARK_TRUNCATE))
|
||||
.append('\n');
|
||||
index++;
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模型返回的 JSON 并校验字段合法性。
|
||||
*
|
||||
* @param content 模型输出内容
|
||||
* @return 关系名称与备注建议
|
||||
*/
|
||||
private AiRelationResultVO parseResult(String content) {
|
||||
JsonNode root;
|
||||
try {
|
||||
root = objectMapper.readTree(stripCodeFence(content));
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new BusinessException("AI 返回内容不是有效 JSON,请重试");
|
||||
}
|
||||
|
||||
String name = truncate(root.path("name").asText(""), NAME_MAX_LENGTH);
|
||||
if (name.isEmpty()) {
|
||||
throw new BusinessException("AI 未返回有效的关系名称,请重试");
|
||||
}
|
||||
|
||||
AiRelationResultVO vo = new AiRelationResultVO();
|
||||
vo.setName(name);
|
||||
vo.setRemark(truncate(root.path("remark").asText(""), REMARK_MAX_LENGTH));
|
||||
vo.setReason(root.path("reason").asText(""));
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取并校验当前用户的 AI 配置。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 有效的 AI 配置
|
||||
*/
|
||||
private AiConfig requireConfig(Long userId) {
|
||||
AiConfig config = aiConfigService.getRawConfig(userId);
|
||||
if (config == null || !Boolean.TRUE.equals(config.getEnabled())) {
|
||||
throw new BusinessException("AI 功能未启用,请先在 AI 设置中开启");
|
||||
}
|
||||
if (config.getApiKey() == null || config.getApiKey().isBlank()) {
|
||||
throw new BusinessException("请先配置 DeepSeek API Key");
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 去除模型可能附加的 Markdown 代码块围栏。
|
||||
*
|
||||
* @param content 原始内容
|
||||
* @return 清理后的内容
|
||||
*/
|
||||
private String stripCodeFence(String content) {
|
||||
if (content == null) {
|
||||
return "";
|
||||
}
|
||||
String cleaned = content.trim();
|
||||
if (cleaned.startsWith("```")) {
|
||||
int firstLineEnd = cleaned.indexOf('\n');
|
||||
if (firstLineEnd > 0) {
|
||||
cleaned = cleaned.substring(firstLineEnd + 1);
|
||||
}
|
||||
if (cleaned.endsWith("```")) {
|
||||
cleaned = cleaned.substring(0, cleaned.length() - 3);
|
||||
}
|
||||
}
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 去除首尾空白。
|
||||
*
|
||||
* @param value 原值
|
||||
* @return 处理后的值,null 转为空字符串
|
||||
*/
|
||||
private String trim(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断字符串到指定长度。
|
||||
*
|
||||
* @param value 原值
|
||||
* @param max 最大长度
|
||||
* @return 截断后的值
|
||||
*/
|
||||
private String truncate(String value, int max) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.length() <= max ? trimmed : trimmed.substring(0, max);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package cn.rosercode.finance.service.impl;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import cn.rosercode.finance.common.BusinessException;
|
||||
import cn.rosercode.finance.converter.VoConverter;
|
||||
import cn.rosercode.finance.dto.TransactionGroupItemRequest;
|
||||
import cn.rosercode.finance.dto.TransactionGroupRequest;
|
||||
import cn.rosercode.finance.entity.Category;
|
||||
import cn.rosercode.finance.entity.Transaction;
|
||||
import cn.rosercode.finance.entity.TransactionGroup;
|
||||
import cn.rosercode.finance.entity.TransactionGroupItem;
|
||||
import cn.rosercode.finance.enums.TransactionGroupRole;
|
||||
import cn.rosercode.finance.enums.TransactionRelationType;
|
||||
import cn.rosercode.finance.mapper.CategoryMapper;
|
||||
import cn.rosercode.finance.mapper.TransactionGroupItemMapper;
|
||||
import cn.rosercode.finance.mapper.TransactionGroupMapper;
|
||||
import cn.rosercode.finance.mapper.TransactionMapper;
|
||||
import cn.rosercode.finance.service.TransactionGroupService;
|
||||
import cn.rosercode.finance.vo.TransactionGroupItemVO;
|
||||
import cn.rosercode.finance.vo.TransactionGroupVO;
|
||||
|
||||
/**
|
||||
* 账单关系组服务实现类,处理关系组的查询、创建、更新、删除与删除账单时的级联清理。
|
||||
*
|
||||
* @date 2026-09-21 20:32:41
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class TransactionGroupServiceImpl implements TransactionGroupService {
|
||||
|
||||
/** 单个关系组成员数量上限 */
|
||||
private static final int MAX_ITEMS = 50;
|
||||
|
||||
/** 关系名称历史返回条数上限 */
|
||||
private static final int NAME_HISTORY_LIMIT = 50;
|
||||
|
||||
private final TransactionGroupMapper transactionGroupMapper;
|
||||
private final TransactionGroupItemMapper transactionGroupItemMapper;
|
||||
private final TransactionMapper transactionMapper;
|
||||
private final CategoryMapper categoryMapper;
|
||||
private final VoConverter voConverter;
|
||||
|
||||
public TransactionGroupServiceImpl(TransactionGroupMapper transactionGroupMapper,
|
||||
TransactionGroupItemMapper transactionGroupItemMapper,
|
||||
TransactionMapper transactionMapper,
|
||||
CategoryMapper categoryMapper,
|
||||
VoConverter voConverter) {
|
||||
this.transactionGroupMapper = transactionGroupMapper;
|
||||
this.transactionGroupItemMapper = transactionGroupItemMapper;
|
||||
this.transactionMapper = transactionMapper;
|
||||
this.categoryMapper = categoryMapper;
|
||||
this.voConverter = voConverter;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public List<TransactionGroupVO> listByTransaction(Long userId, Long transactionId) {
|
||||
// 校验账单归属当前用户
|
||||
Transaction transaction = transactionMapper.selectById(transactionId);
|
||||
if (transaction == null || !transaction.getUserId().equals(userId)) {
|
||||
throw new BusinessException("记录不存在");
|
||||
}
|
||||
List<TransactionGroupItem> memberships = transactionGroupItemMapper.selectList(
|
||||
new LambdaQueryWrapper<TransactionGroupItem>()
|
||||
.eq(TransactionGroupItem::getTransactionId, transactionId));
|
||||
if (memberships.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Set<Long> groupIds = memberships.stream()
|
||||
.map(TransactionGroupItem::getGroupId)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
List<TransactionGroup> groups = transactionGroupMapper.selectList(new LambdaQueryWrapper<TransactionGroup>()
|
||||
.eq(TransactionGroup::getUserId, userId)
|
||||
.in(TransactionGroup::getId, groupIds));
|
||||
if (groups.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return toGroupVOs(groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public List<String> listNames(Long userId, String type) {
|
||||
LambdaQueryWrapper<TransactionGroup> wrapper = new LambdaQueryWrapper<TransactionGroup>()
|
||||
.select(TransactionGroup::getName, TransactionGroup::getUpdatedAt)
|
||||
.eq(TransactionGroup::getUserId, userId)
|
||||
.isNotNull(TransactionGroup::getName)
|
||||
.ne(TransactionGroup::getName, "");
|
||||
// 指定合法类型时仅返回该类型的历史名称
|
||||
if (TransactionRelationType.isValid(type)) {
|
||||
wrapper.eq(TransactionGroup::getType, type);
|
||||
}
|
||||
List<TransactionGroup> groups = transactionGroupMapper.selectList(wrapper);
|
||||
// 按更新时间倒序(最近使用优先),去重后截断
|
||||
return groups.stream()
|
||||
.sorted(Comparator.comparing(TransactionGroup::getUpdatedAt,
|
||||
Comparator.nullsLast(Comparator.reverseOrder())))
|
||||
.map(group -> group.getName().trim())
|
||||
.filter(name -> !name.isEmpty())
|
||||
.distinct()
|
||||
.limit(NAME_HISTORY_LIMIT)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public Long create(Long userId, TransactionGroupRequest request) {
|
||||
String type = requireType(request.getType());
|
||||
List<TransactionGroupItemRequest> items = normalizeItems(type, request.getItems());
|
||||
List<Long> transactionIds = transactionIds(items);
|
||||
validateTransactions(userId, transactionIds);
|
||||
validateNotInSameTypeGroup(userId, type, transactionIds, null);
|
||||
|
||||
TransactionGroup group = new TransactionGroup();
|
||||
group.setUserId(userId);
|
||||
group.setType(type);
|
||||
group.setName(request.getName());
|
||||
group.setRemark(request.getRemark());
|
||||
transactionGroupMapper.insert(group);
|
||||
|
||||
insertItems(group.getId(), items);
|
||||
log.info("新增账单关系组: userId={}, groupId={}, type={}, 成员={}",
|
||||
userId, group.getId(), type, items.size());
|
||||
return group.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void update(Long userId, Long id, TransactionGroupRequest request) {
|
||||
requireGroup(userId, id);
|
||||
String type = requireType(request.getType());
|
||||
List<TransactionGroupItemRequest> items = normalizeItems(type, request.getItems());
|
||||
List<Long> transactionIds = transactionIds(items);
|
||||
validateTransactions(userId, transactionIds);
|
||||
// 同类型唯一性校验时排除当前组自身
|
||||
validateNotInSameTypeGroup(userId, type, transactionIds, id);
|
||||
|
||||
TransactionGroup group = new TransactionGroup();
|
||||
group.setId(id);
|
||||
group.setType(type);
|
||||
group.setName(request.getName());
|
||||
group.setRemark(request.getRemark());
|
||||
transactionGroupMapper.updateById(group);
|
||||
|
||||
transactionGroupItemMapper.delete(new LambdaQueryWrapper<TransactionGroupItem>()
|
||||
.eq(TransactionGroupItem::getGroupId, id));
|
||||
insertItems(id, items);
|
||||
log.info("更新账单关系组: userId={}, groupId={}, type={}, 成员={}", userId, id, type, items.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void delete(Long userId, Long id) {
|
||||
requireGroup(userId, id);
|
||||
transactionGroupItemMapper.delete(new LambdaQueryWrapper<TransactionGroupItem>()
|
||||
.eq(TransactionGroupItem::getGroupId, id));
|
||||
transactionGroupMapper.deleteById(id);
|
||||
log.info("删除账单关系组: userId={}, groupId={}", userId, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void removeTransactionFromGroups(Long userId, List<Long> transactionIds) {
|
||||
if (transactionIds == null || transactionIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<TransactionGroupItem> memberships = transactionGroupItemMapper.selectList(
|
||||
new LambdaQueryWrapper<TransactionGroupItem>()
|
||||
.in(TransactionGroupItem::getTransactionId, transactionIds));
|
||||
if (memberships.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Set<Long> groupIds = memberships.stream()
|
||||
.map(TransactionGroupItem::getGroupId)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
// 仅处理属于当前用户的关系组
|
||||
List<TransactionGroup> groups = transactionGroupMapper.selectList(new LambdaQueryWrapper<TransactionGroup>()
|
||||
.eq(TransactionGroup::getUserId, userId)
|
||||
.in(TransactionGroup::getId, groupIds));
|
||||
if (groups.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
// 移除被删账单在这些组中的成员记录
|
||||
transactionGroupItemMapper.delete(new LambdaQueryWrapper<TransactionGroupItem>()
|
||||
.in(TransactionGroupItem::getGroupId, groupIds)
|
||||
.in(TransactionGroupItem::getTransactionId, transactionIds));
|
||||
|
||||
// 逐个检查组是否仍有效:不足两条,或父子组失去父账单时整组移除
|
||||
for (TransactionGroup group : groups) {
|
||||
List<TransactionGroupItem> remaining = transactionGroupItemMapper.selectList(
|
||||
new LambdaQueryWrapper<TransactionGroupItem>()
|
||||
.eq(TransactionGroupItem::getGroupId, group.getId()));
|
||||
boolean valid = remaining.size() >= 2;
|
||||
if (valid && TransactionRelationType.PARENT_CHILD.name().equals(group.getType())) {
|
||||
valid = remaining.stream()
|
||||
.anyMatch(item -> TransactionGroupRole.PARENT.name().equals(item.getRole()));
|
||||
}
|
||||
if (!valid) {
|
||||
transactionGroupItemMapper.delete(new LambdaQueryWrapper<TransactionGroupItem>()
|
||||
.eq(TransactionGroupItem::getGroupId, group.getId()));
|
||||
transactionGroupMapper.deleteById(group.getId());
|
||||
log.info("账单删除后关系组失效已移除: userId={}, groupId={}", userId, group.getId());
|
||||
}
|
||||
}
|
||||
log.info("账单删除后清理关系: userId={}, 账单={}, 涉及组={}", userId, transactionIds.size(), groups.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并返回合法的关系类型编码。
|
||||
*
|
||||
* @param type 关系类型编码
|
||||
* @return 合法的关系类型编码
|
||||
*/
|
||||
private String requireType(String type) {
|
||||
if (!TransactionRelationType.isValid(type)) {
|
||||
throw new BusinessException("关系类型不合法");
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验关系组归属并返回实体。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param id 关系组ID
|
||||
* @return 关系组实体
|
||||
*/
|
||||
private TransactionGroup requireGroup(Long userId, Long id) {
|
||||
TransactionGroup group = transactionGroupMapper.selectById(id);
|
||||
if (group == null || !group.getUserId().equals(userId)) {
|
||||
throw new BusinessException("关系组不存在");
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化成员列表:去重、校验数量,并按关系类型规范角色与排序。
|
||||
*
|
||||
* @param type 关系类型
|
||||
* @param items 原始成员请求列表
|
||||
* @return 归一化后的成员列表
|
||||
*/
|
||||
private List<TransactionGroupItemRequest> normalizeItems(String type, List<TransactionGroupItemRequest> items) {
|
||||
if (items == null || items.isEmpty()) {
|
||||
throw new BusinessException("关系组至少包含两条账单");
|
||||
}
|
||||
// 按交易ID去重并保留输入顺序
|
||||
Map<Long, TransactionGroupItemRequest> unique = new LinkedHashMap<>();
|
||||
for (TransactionGroupItemRequest item : items) {
|
||||
if (item == null || item.getTransactionId() == null) {
|
||||
throw new BusinessException("关联账单不能为空");
|
||||
}
|
||||
unique.putIfAbsent(item.getTransactionId(), item);
|
||||
}
|
||||
if (unique.size() < 2) {
|
||||
throw new BusinessException("关系组至少包含两条账单");
|
||||
}
|
||||
if (unique.size() > MAX_ITEMS) {
|
||||
throw new BusinessException("单个关系组最多包含" + MAX_ITEMS + "条账单");
|
||||
}
|
||||
|
||||
boolean parentChild = TransactionRelationType.PARENT_CHILD.name().equals(type);
|
||||
if (parentChild) {
|
||||
long parentCount = unique.values().stream()
|
||||
.filter(item -> TransactionGroupRole.PARENT.name().equals(item.getRole()))
|
||||
.count();
|
||||
if (parentCount != 1) {
|
||||
throw new BusinessException("父子关系必须且只能指定一条父账单");
|
||||
}
|
||||
}
|
||||
|
||||
List<TransactionGroupItemRequest> normalized = new ArrayList<>(unique.size());
|
||||
int index = 0;
|
||||
for (TransactionGroupItemRequest item : unique.values()) {
|
||||
if (parentChild) {
|
||||
item.setRole(TransactionGroupRole.PARENT.name().equals(item.getRole())
|
||||
? TransactionGroupRole.PARENT.name() : TransactionGroupRole.CHILD.name());
|
||||
} else {
|
||||
item.setRole(TransactionGroupRole.MEMBER.name());
|
||||
}
|
||||
if (item.getSortOrder() == null) {
|
||||
item.setSortOrder(index);
|
||||
}
|
||||
index++;
|
||||
normalized.add(item);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取成员请求中的交易记录ID列表。
|
||||
*
|
||||
* @param items 成员请求列表
|
||||
* @return 交易记录ID列表
|
||||
*/
|
||||
private List<Long> transactionIds(List<TransactionGroupItemRequest> items) {
|
||||
return items.stream().map(TransactionGroupItemRequest::getTransactionId).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验成员账单均存在且归属当前用户。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param transactionIds 交易记录ID列表
|
||||
*/
|
||||
private void validateTransactions(Long userId, List<Long> transactionIds) {
|
||||
List<Transaction> transactions = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
|
||||
.select(Transaction::getId)
|
||||
.eq(Transaction::getUserId, userId)
|
||||
.in(Transaction::getId, transactionIds));
|
||||
if (transactions.size() != transactionIds.size()) {
|
||||
throw new BusinessException("存在无效的关联账单");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验成员账单未加入同类型的其它关系组。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param type 关系类型
|
||||
* @param transactionIds 交易记录ID列表
|
||||
* @param excludeGroupId 需排除的关系组ID(更新场景为当前组),可为 null
|
||||
*/
|
||||
private void validateNotInSameTypeGroup(Long userId, String type, List<Long> transactionIds,
|
||||
Long excludeGroupId) {
|
||||
List<TransactionGroup> sameTypeGroups = transactionGroupMapper.selectList(
|
||||
new LambdaQueryWrapper<TransactionGroup>()
|
||||
.select(TransactionGroup::getId)
|
||||
.eq(TransactionGroup::getUserId, userId)
|
||||
.eq(TransactionGroup::getType, type));
|
||||
if (sameTypeGroups.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<Long> groupIds = sameTypeGroups.stream().map(TransactionGroup::getId).collect(Collectors.toList());
|
||||
LambdaQueryWrapper<TransactionGroupItem> wrapper = new LambdaQueryWrapper<TransactionGroupItem>()
|
||||
.in(TransactionGroupItem::getGroupId, groupIds)
|
||||
.in(TransactionGroupItem::getTransactionId, transactionIds);
|
||||
if (excludeGroupId != null) {
|
||||
wrapper.ne(TransactionGroupItem::getGroupId, excludeGroupId);
|
||||
}
|
||||
if (!transactionGroupItemMapper.selectList(wrapper).isEmpty()) {
|
||||
throw new BusinessException("存在已加入同类型关系的账单,请先解除原关系");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量写入关系组成员。
|
||||
*
|
||||
* @param groupId 关系组ID
|
||||
* @param items 归一化后的成员列表
|
||||
*/
|
||||
private void insertItems(Long groupId, List<TransactionGroupItemRequest> items) {
|
||||
for (TransactionGroupItemRequest item : items) {
|
||||
TransactionGroupItem entity = new TransactionGroupItem();
|
||||
entity.setGroupId(groupId);
|
||||
entity.setTransactionId(item.getTransactionId());
|
||||
entity.setRole(item.getRole());
|
||||
entity.setSortOrder(item.getSortOrder() == null ? 0 : item.getSortOrder());
|
||||
transactionGroupItemMapper.insert(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将关系组实体列表转换为视图对象,并批量填充成员账单快照。
|
||||
*
|
||||
* @param groups 关系组实体列表
|
||||
* @return 关系组视图对象列表
|
||||
*/
|
||||
private List<TransactionGroupVO> toGroupVOs(List<TransactionGroup> groups) {
|
||||
List<Long> groupIds = groups.stream().map(TransactionGroup::getId).collect(Collectors.toList());
|
||||
List<TransactionGroupItem> items = transactionGroupItemMapper.selectList(
|
||||
new LambdaQueryWrapper<TransactionGroupItem>()
|
||||
.in(TransactionGroupItem::getGroupId, groupIds)
|
||||
.orderByAsc(TransactionGroupItem::getSortOrder)
|
||||
.orderByAsc(TransactionGroupItem::getId));
|
||||
|
||||
// 批量加载成员账单与分类,避免 N+1 查询
|
||||
List<Long> transactionIds = items.stream()
|
||||
.map(TransactionGroupItem::getTransactionId)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
Map<Long, Transaction> transactionMap = transactionIds.isEmpty() ? Map.of()
|
||||
: transactionMapper.selectBatchIds(transactionIds).stream()
|
||||
.collect(Collectors.toMap(Transaction::getId, transaction -> transaction));
|
||||
List<Long> categoryIds = transactionMap.values().stream()
|
||||
.map(Transaction::getCategoryId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
Map<Long, Category> categoryMap = categoryIds.isEmpty() ? Map.of()
|
||||
: categoryMapper.selectBatchIds(categoryIds).stream()
|
||||
.collect(Collectors.toMap(Category::getId, category -> category));
|
||||
|
||||
Map<Long, List<TransactionGroupItemVO>> itemsByGroup = new HashMap<>();
|
||||
for (TransactionGroupItem item : items) {
|
||||
TransactionGroupItemVO itemVO = voConverter.toTransactionGroupItemVO(item);
|
||||
Transaction transaction = transactionMap.get(item.getTransactionId());
|
||||
if (transaction != null) {
|
||||
itemVO.setType(transaction.getType());
|
||||
itemVO.setAmount(transaction.getAmount());
|
||||
itemVO.setChannel(transaction.getChannel());
|
||||
itemVO.setRemark(transaction.getRemark());
|
||||
itemVO.setRemarkHidden(Boolean.TRUE.equals(transaction.getRemarkHidden()));
|
||||
itemVO.setTransactionTime(transaction.getTransactionTime());
|
||||
Category category = transaction.getCategoryId() == null
|
||||
? null : categoryMap.get(transaction.getCategoryId());
|
||||
itemVO.setCategoryName(category == null ? "" : category.getName());
|
||||
itemVO.setCategoryIcon(category == null ? "" : category.getIcon());
|
||||
}
|
||||
itemsByGroup.computeIfAbsent(item.getGroupId(), key -> new ArrayList<>()).add(itemVO);
|
||||
}
|
||||
|
||||
List<TransactionGroupVO> result = new ArrayList<>(groups.size());
|
||||
for (TransactionGroup group : groups) {
|
||||
TransactionGroupVO vo = voConverter.toTransactionGroupVO(group);
|
||||
vo.setItems(itemsByGroup.getOrDefault(group.getId(), List.of()));
|
||||
result.add(vo);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import cn.rosercode.finance.entity.AiImageTransaction;
|
||||
import cn.rosercode.finance.entity.Category;
|
||||
import cn.rosercode.finance.entity.Tag;
|
||||
import cn.rosercode.finance.entity.Transaction;
|
||||
import cn.rosercode.finance.entity.TransactionGroupItem;
|
||||
import cn.rosercode.finance.entity.TransactionTag;
|
||||
import cn.rosercode.finance.enums.ChannelType;
|
||||
import cn.rosercode.finance.enums.TransactionType;
|
||||
@@ -39,9 +40,11 @@ import cn.rosercode.finance.mapper.AiImageMapper;
|
||||
import cn.rosercode.finance.mapper.AiImageTransactionMapper;
|
||||
import cn.rosercode.finance.mapper.CategoryMapper;
|
||||
import cn.rosercode.finance.mapper.TagMapper;
|
||||
import cn.rosercode.finance.mapper.TransactionGroupItemMapper;
|
||||
import cn.rosercode.finance.mapper.TransactionMapper;
|
||||
import cn.rosercode.finance.mapper.TransactionTagMapper;
|
||||
import cn.rosercode.finance.service.ChannelService;
|
||||
import cn.rosercode.finance.service.TransactionGroupService;
|
||||
import cn.rosercode.finance.service.TransactionService;
|
||||
import cn.rosercode.finance.util.TransactionDedupUtils;
|
||||
import cn.rosercode.finance.vo.TagVO;
|
||||
@@ -64,7 +67,9 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
private final TransactionTagMapper transactionTagMapper;
|
||||
private final AiImageMapper aiImageMapper;
|
||||
private final AiImageTransactionMapper aiImageTransactionMapper;
|
||||
private final TransactionGroupItemMapper transactionGroupItemMapper;
|
||||
private final ChannelService channelService;
|
||||
private final TransactionGroupService transactionGroupService;
|
||||
private final VoConverter voConverter;
|
||||
|
||||
public TransactionServiceImpl(TransactionMapper transactionMapper,
|
||||
@@ -73,7 +78,9 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
TransactionTagMapper transactionTagMapper,
|
||||
AiImageMapper aiImageMapper,
|
||||
AiImageTransactionMapper aiImageTransactionMapper,
|
||||
TransactionGroupItemMapper transactionGroupItemMapper,
|
||||
ChannelService channelService,
|
||||
TransactionGroupService transactionGroupService,
|
||||
VoConverter voConverter) {
|
||||
this.transactionMapper = transactionMapper;
|
||||
this.categoryMapper = categoryMapper;
|
||||
@@ -81,7 +88,9 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
this.transactionTagMapper = transactionTagMapper;
|
||||
this.aiImageMapper = aiImageMapper;
|
||||
this.aiImageTransactionMapper = aiImageTransactionMapper;
|
||||
this.transactionGroupItemMapper = transactionGroupItemMapper;
|
||||
this.channelService = channelService;
|
||||
this.transactionGroupService = transactionGroupService;
|
||||
this.voConverter = voConverter;
|
||||
}
|
||||
|
||||
@@ -194,6 +203,7 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
// 批量填充标签与来源图片
|
||||
fillTags(voList);
|
||||
fillImages(voList);
|
||||
fillRelationGroupCount(voList);
|
||||
|
||||
return new PageResult<>(voList, result.getTotal(), result.getCurrent(), result.getSize());
|
||||
}
|
||||
@@ -387,6 +397,7 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
// 先清理标签关联,再删除记录
|
||||
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
|
||||
.eq(TransactionTag::getTransactionId, id));
|
||||
transactionGroupService.removeTransactionFromGroups(userId, List.of(id));
|
||||
transactionMapper.deleteById(id);
|
||||
log.info("删除交易记录: userId={}, id={}", userId, id);
|
||||
}
|
||||
@@ -416,6 +427,7 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
// 先清理标签关联,再批量删除记录
|
||||
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
|
||||
.in(TransactionTag::getTransactionId, ownedIds));
|
||||
transactionGroupService.removeTransactionFromGroups(userId, ownedIds);
|
||||
int deleted = transactionMapper.deleteByIds(ownedIds);
|
||||
log.info("批量删除交易记录: userId={}, 请求={}, 实际={}", userId, ids.size(), deleted);
|
||||
return deleted;
|
||||
@@ -680,6 +692,26 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量填充交易视图对象所属的关系组数量
|
||||
*
|
||||
* @param voList 交易视图对象列表
|
||||
*/
|
||||
private void fillRelationGroupCount(List<TransactionVO> voList) {
|
||||
if (voList.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<Long> transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList());
|
||||
List<TransactionGroupItem> items = transactionGroupItemMapper.selectList(
|
||||
new LambdaQueryWrapper<TransactionGroupItem>()
|
||||
.in(TransactionGroupItem::getTransactionId, transactionIds));
|
||||
Map<Long, Long> counts = items.stream()
|
||||
.collect(Collectors.groupingBy(TransactionGroupItem::getTransactionId, Collectors.counting()));
|
||||
for (TransactionVO vo : voList) {
|
||||
vo.setRelationGroupCount(counts.getOrDefault(vo.getId(), 0L).intValue());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用排序条件:默认按交易时间降序,支持按交易时间或金额升/降序。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package cn.rosercode.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* AI 生成账单关系结果视图对象,字段与关系组表单对应。
|
||||
*
|
||||
* @date 2026-09-21 21:08:21
|
||||
*/
|
||||
@Data
|
||||
public class AiRelationResultVO {
|
||||
|
||||
/** 建议的关系名称 */
|
||||
private String name;
|
||||
|
||||
/** 建议的关系备注 */
|
||||
private String remark;
|
||||
|
||||
/** 生成理由,用于向用户说明 */
|
||||
private String reason;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package cn.rosercode.finance.vo;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 账单关系组成员视图对象,包含成员角色与所关联账单的展示快照。
|
||||
*
|
||||
* @date 2026-09-21 20:32:41
|
||||
*/
|
||||
@Data
|
||||
public class TransactionGroupItemVO {
|
||||
|
||||
/** 交易记录ID */
|
||||
private Long transactionId;
|
||||
|
||||
/** 组内角色:PARENT-父,CHILD-子,MEMBER-成员 */
|
||||
private String role;
|
||||
|
||||
/** 排序序号,用于同级链路顺序与展示顺序 */
|
||||
private Integer sortOrder;
|
||||
|
||||
/** 交易类型(收入/支出) */
|
||||
private String type;
|
||||
|
||||
/** 交易金额 */
|
||||
private BigDecimal amount;
|
||||
|
||||
/** 交易渠道 */
|
||||
private String channel;
|
||||
|
||||
/** 备注 */
|
||||
private String remark;
|
||||
|
||||
/** 备注是否隐藏 */
|
||||
private Boolean remarkHidden;
|
||||
|
||||
/** 交易时间 */
|
||||
private LocalDateTime transactionTime;
|
||||
|
||||
/** 分类名称 */
|
||||
private String categoryName;
|
||||
|
||||
/** 分类图标 */
|
||||
private String categoryIcon;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package cn.rosercode.finance.vo;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 账单关系组视图对象,用于向前端返回关系组及其成员账单。
|
||||
*
|
||||
* @date 2026-09-21 20:32:41
|
||||
*/
|
||||
@Data
|
||||
public class TransactionGroupVO {
|
||||
|
||||
/** 关系组ID */
|
||||
private Long id;
|
||||
|
||||
/** 关系类型:PARENT_CHILD-父子,SIBLING-同级 */
|
||||
private String type;
|
||||
|
||||
/** 关系组名称 */
|
||||
private String name;
|
||||
|
||||
/** 备注 */
|
||||
private String remark;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** 更新时间 */
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/** 关系组成员列表 */
|
||||
private List<TransactionGroupItemVO> items;
|
||||
}
|
||||
@@ -56,4 +56,7 @@ public class TransactionVO {
|
||||
|
||||
/** 来源图片ID列表(图片记账生成时存在,手动记账为空) */
|
||||
private List<Long> imageIds;
|
||||
|
||||
/** 所属关系组数量(父子/同级关系),无关系时为 0 */
|
||||
private Integer relationGroupCount;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
你是个人记账应用的账单关系助手。用户会给出一组账单的关系类型与成员账单特征,请为这组关系生成一个简洁的名称和一句备注。要求:
|
||||
1. name 为 4-12 个汉字的名称,概括这组账单的关系(如「交通卡充值」「淘宝购物支付链」「借款给朋友」);
|
||||
2. remark 为一句话说明(不超过 40 个汉字),点明成员账单之间的关联含义(如充值后分次扣减、同一笔消费的多来源记录、借出后对方归还);
|
||||
3. 借贷场景:当成员为同一交易对方(可结合备注判断)、发生在不同时间,且「支出合计」与「收入合计」大致相等(方向相反,可含多笔)时,应判定为借贷往来(借出—归还);若一笔借出对应多笔还款,说明为分次归还;
|
||||
4. 不要提及或推算任何金额;
|
||||
5. 只输出 JSON,不要输出任何多余内容。输出格式:{"name":"交通卡充值","remark":"充值后分次乘车扣减","reason":"简要中文理由"}
|
||||
@@ -66,6 +66,30 @@ CREATE TABLE IF NOT EXISTS t_transaction_tag (
|
||||
INDEX idx_tag_id (tag_id) COMMENT '标签ID索引'
|
||||
);
|
||||
|
||||
-- 账单关系组表:将多笔账单按父子或同级关系聚合为一个关系组
|
||||
CREATE TABLE IF NOT EXISTS t_transaction_group (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关系组ID,主键自增',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
user_id BIGINT NOT NULL COMMENT '所属用户ID',
|
||||
type VARCHAR(20) NOT NULL COMMENT '关系类型:PARENT_CHILD-父子,SIBLING-同级',
|
||||
name VARCHAR(100) COMMENT '关系组名称',
|
||||
remark VARCHAR(255) COMMENT '备注',
|
||||
INDEX idx_user_id (user_id) COMMENT '用户ID索引'
|
||||
);
|
||||
|
||||
-- 账单关系组成员表:关系组与账单的关联及组内角色
|
||||
CREATE TABLE IF NOT EXISTS t_transaction_group_item (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关联ID,主键自增',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
group_id BIGINT NOT NULL COMMENT '关系组ID',
|
||||
transaction_id BIGINT NOT NULL COMMENT '交易记录ID',
|
||||
role VARCHAR(10) NOT NULL COMMENT '组内角色:PARENT-父,CHILD-子,MEMBER-成员',
|
||||
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序序号,用于同级链路顺序',
|
||||
UNIQUE INDEX uk_group_transaction (group_id, transaction_id) COMMENT '关系组+交易唯一索引',
|
||||
INDEX idx_transaction_id (transaction_id) COMMENT '交易记录ID索引'
|
||||
);
|
||||
|
||||
-- AI 配置表:存储每个用户的 AI 智能建议相关配置
|
||||
CREATE TABLE IF NOT EXISTS t_ai_config (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '配置ID,主键自增',
|
||||
|
||||
@@ -70,6 +70,28 @@ CREATE TABLE IF NOT EXISTS t_transaction_tag (
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_transaction_tag ON t_transaction_tag (transaction_id, tag_id);;
|
||||
CREATE INDEX IF NOT EXISTS idx_transaction_tag_tag_id ON t_transaction_tag (tag_id);;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS t_transaction_group (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
|
||||
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
|
||||
user_id INTEGER NOT NULL,
|
||||
type VARCHAR(20) NOT NULL,
|
||||
name VARCHAR(100),
|
||||
remark VARCHAR(255)
|
||||
);;
|
||||
CREATE INDEX IF NOT EXISTS idx_transaction_group_user_id ON t_transaction_group (user_id);;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS t_transaction_group_item (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
|
||||
group_id INTEGER NOT NULL,
|
||||
transaction_id INTEGER NOT NULL,
|
||||
role VARCHAR(10) NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_transaction_group_item ON t_transaction_group_item (group_id, transaction_id);;
|
||||
CREATE INDEX IF NOT EXISTS idx_transaction_group_item_transaction_id ON t_transaction_group_item (transaction_id);;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS t_ai_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
|
||||
@@ -143,6 +165,14 @@ BEGIN
|
||||
UPDATE t_transaction SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
|
||||
END;;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_transaction_group_updated_at
|
||||
AFTER UPDATE ON t_transaction_group
|
||||
FOR EACH ROW
|
||||
WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE t_transaction_group SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
|
||||
END;;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_ai_config_updated_at
|
||||
AFTER UPDATE ON t_ai_config
|
||||
FOR EACH ROW
|
||||
|
||||
Reference in New Issue
Block a user