Files
ledgerly/frontend/src/components/TransactionDetailModal.vue
T
rosercode f390246528 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/* 接口与关系名称历史接口
- 补充中英日三语文案
2026-09-21 22:23:47 +08:00

261 lines
8.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>