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:
@@ -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>
|
||||
Reference in New Issue
Block a user