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