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