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:
2026-09-21 22:23:47 +08:00
parent 06fd80d55f
commit f390246528
35 changed files with 2947 additions and 111 deletions
@@ -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>