- 新增 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/* 接口与关系名称历史接口 - 补充中英日三语文案
261 lines
8.5 KiB
Vue
261 lines
8.5 KiB
Vue
<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>
|