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:
2026-09-21 22:23:47 +08:00
parent 06fd80d55f
commit f390246528
35 changed files with 2947 additions and 111 deletions
+105 -110
View File
@@ -3,11 +3,11 @@
import { ref, onMounted, onBeforeUnmount, computed, h, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDivider, NDropdown, NUpload, NUploadDragger, NTabs, NTabPane, NSpin } from 'naive-ui'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDivider, NDropdown, NUpload, NUploadDragger, NTabs, NTabPane } from 'naive-ui'
import { EyeOutline, EyeOffOutline, SparklesOutline, BulbOutline, RefreshOutline, CloudUploadOutline, CheckmarkCircleOutline } from '@vicons/ionicons5'
import type { DataTableColumns, UploadFileInfo } from 'naive-ui'
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags, getChannels, batchDeleteTransactions, batchCreateTransactions } from '../api/transaction'
import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi, parseChannelByAi, getAiImageBlob } from '../api/ai'
import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi, parseChannelByAi } from '../api/ai'
import { createChannel } from '../api/channel'
import type { AiSuggestion, AiQueryResult, AiRecordResult, AiRecordImageResult } from '../api/ai'
import { getExcludedDisplayStyle } from '../api/setting'
@@ -18,6 +18,10 @@ import { renderCategoryLabel } from '../utils/render'
import { useKeywordHistory } from '../composables/useKeywordHistory'
import { openSse } from '../utils/sse'
import BillImportModal from './BillImportModal.vue'
import TransactionDetailModal from '../components/TransactionDetailModal.vue'
import TransactionRelationEditorModal from '../components/TransactionRelationEditorModal.vue'
import TransactionRelationModal from '../components/TransactionRelationModal.vue'
import type { RelationMemberDraft } from '../api/transactionGroup'
const message = useMessage() // 消息提示
const dialog = useDialog() // 对话框
@@ -41,6 +45,8 @@ interface TransactionItem {
tags: TagOption[]
/** 来源图片ID列表(图片记账生成时存在) */
imageIds: number[]
/** 所属关系组数量(父子/同级关系) */
relationGroupCount: number
createdAt: string
}
@@ -125,6 +131,12 @@ const excludedDisplayStyle = ref<'strikethrough' | 'muted'>('strikethrough')
const submitting = ref(false) // 表单提交状态
const showModal = ref(false) // 新增/编辑弹窗显示状态
const showImportModal = ref(false) // 账单导入弹窗显示状态
const showDetailModal = ref(false) // 账单详情弹窗显示状态
const detailTransaction = ref<TransactionItem | null>(null) // 当前查看详情的账单
const showRelationEditor = ref(false) // 勾选账单建立关系弹窗显示状态
const relationEditorMembers = ref<RelationMemberDraft[]>([]) // 建立关系时的预填成员
const showRelationModal = ref(false) // 关系查看弹窗显示状态
const relationTransaction = ref<TransactionItem | null>(null) // 当前查看关系的账单
const showBatchModal = ref(false) // 批量设置分类弹窗显示状态
const showAiModal = ref(false) // AI 建议弹窗显示状态
const aiEnabled = ref(false) // AI 建议是否启用
@@ -226,18 +238,6 @@ const checkedAiRecordKeys = ref<number[]>([]) // 图片识别结果勾选的行
const aiRecordImporting = ref(false) // 图片记账导入状态
const aiRecordProgress = ref<{ time: string; text: string }[]>([]) // 图片识别进度日志(含接收时间)
let aiRecordStreamController: AbortController | null = null // 图片识别 SSE 连接控制器
const showImageViewModal = ref(false) // 来源图片查看弹窗显示状态
/** 来源图片查看项:默认加载缩略图,用户可按需切换为原图 */
interface ImageViewItem {
id: number // 图片ID
url: string // 当前展示的 objectURL
isOriginal: boolean // 是否已加载原图
loading: boolean // 原图加载状态
}
const imageViewItems = ref<ImageViewItem[]>([]) // 来源图片列表
const imageViewLoading = ref(false) // 来源图片加载状态
const showRawModal = ref(false) // 原始账单数据弹窗显示状态
const rawDataText = ref('') // 格式化后的原始账单数据文本
const batchExpenseCategoryId = ref<number | null>(null) // 批量设置的支出分类
@@ -261,6 +261,7 @@ const categories = ref<CategoryOption[]>([]) // 分类选项列表
const allTags = ref<TagOption[]>([]) // 全部标签(用于筛选、编辑、批量)
const revealedRemarkIds = ref<number[]>([]) // 临时展开查看原文的备注行 ID(不持久,刷新/翻页后重置)
const checkedTransactionIds = ref<number[]>([]) // 表格多选选中的记录ID(用于批量删除)
const selectionMode = ref(false) // 批量选择模式:开启后才显示多选列
// 表单数据(新增/编辑共用)
const form = ref({
@@ -509,8 +510,8 @@ function createColumns(): DataTableColumns<TransactionItem> {
// 读取展示样式,使列配置随样式偏好变化而重新计算
const muted = excludedDisplayStyle.value === 'muted'
return [
// 多选列:用于批量操作(如批量删除)
{ type: 'selection' },
// 多选列:仅在批量选择模式下显示,默认不渲染复选框
...(selectionMode.value ? [{ type: 'selection' as const }] : []),
{
// 序号列:按分页计算全局序号
title: t('transaction.column.index'), key: 'index', width: 70, align: 'center',
@@ -601,13 +602,13 @@ function createColumns(): DataTableColumns<TransactionItem> {
},
},
{
title: t('transaction.sourceImages'), key: 'imageIds', width: 110, align: 'center',
title: t('transaction.relation.column'), key: 'relationGroupCount', width: 110, align: 'center',
render(row) {
if (!row.imageIds || row.imageIds.length === 0) {
return h('span', { style: { color: '#ccc' } }, '-')
if (!row.relationGroupCount) {
return h('span', { style: { color: '#ccc' } }, t('transaction.relation.none'))
}
return h(NButton, { size: 'small', quaternary: true, type: 'primary', onClick: () => openImageView(row.imageIds) },
{ default: () => t('transaction.viewImages', { count: row.imageIds.length }) })
return h(NButton, { size: 'small', quaternary: true, type: 'info', onClick: () => openRelation(row) },
{ default: () => t('transaction.relation.viewCount', { count: row.relationGroupCount }) })
},
},
{
@@ -615,8 +616,8 @@ function createColumns(): DataTableColumns<TransactionItem> {
render(row) {
return h(NSpace, null, {
default: () => [
h(NButton, { size: 'small', onClick: () => openDetail(row) }, { default: () => t('transaction.detail.button') }),
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }),
h(NButton, { size: 'small', disabled: !row.rawData, onClick: () => handleViewRaw(row) }, { default: () => t('transaction.rawData') }),
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => t('common.delete') }),
],
})
@@ -711,18 +712,49 @@ watch(sortValue, () => {
fetchData()
})
/** 查看某条记录的原始账单数据(格式化为缩进 JSON) */
function handleViewRaw(row: TransactionItem) {
if (!row.rawData) {
message.warning(t('transaction.noRawData'))
/** 打开账单详情弹窗 */
function openDetail(row: TransactionItem) {
detailTransaction.value = row
showDetailModal.value = true
}
/** 打开账单关系弹窗 */
function openRelation(row: TransactionItem) {
relationTransaction.value = row
showRelationModal.value = true
}
/** 从详情弹窗进入编辑 */
function handleDetailEdit() {
if (detailTransaction.value) {
showDetailModal.value = false
handleEdit(detailTransaction.value)
}
}
/** 以表格勾选的账单为成员,打开建立关系弹窗 */
function handleCreateRelation() {
if (checkedTransactionIds.value.length < 2) {
message.warning(t('transaction.relation.selectAtLeastTwo'))
return
}
try {
rawDataText.value = JSON.stringify(JSON.parse(row.rawData), null, 2)
} catch (e) {
rawDataText.value = row.rawData
}
showRawModal.value = true
const selected = transactions.value.filter((row) => checkedTransactionIds.value.includes(row.id))
relationEditorMembers.value = selected.map((row) => ({
transactionId: row.id,
type: row.type,
amount: row.amount,
channel: row.channel,
remark: row.remark,
transactionTime: row.transactionTime,
categoryName: row.categoryName,
categoryIcon: row.categoryIcon,
}))
showRelationEditor.value = true
}
/** 建立关系保存成功后刷新列表(选择随刷新清空) */
function onRelationEditorSaved() {
fetchData()
}
/** 查看图片记账结果行的原始数据(格式化为缩进 JSON) */
@@ -736,57 +768,6 @@ function handleViewAiRecordRaw(row: AiRecordEditRow) {
showRawModal.value = true
}
/** 从接口响应中提取图片 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 openImageView(imageIds: number[]) {
if (!imageIds || imageIds.length === 0) return
closeImageView()
showImageViewModal.value = true
imageViewLoading.value = true
try {
const items: ImageViewItem[] = []
for (const id of imageIds) {
const res: any = await getAiImageBlob(id, true)
items.push({ id, url: URL.createObjectURL(extractBlob(res)), isOriginal: false, loading: false })
}
imageViewItems.value = items
} catch (e: any) {
message.error(e.message || t('common.loadFailed'))
} finally {
imageViewLoading.value = false
}
}
/** 将指定来源图片切换为原图(替换并释放旧缩略图 objectURL) */
async function loadOriginalImage(item: ImageViewItem) {
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 closeImageView() {
for (const item of imageViewItems.value) {
URL.revokeObjectURL(item.url)
}
imageViewItems.value = []
showImageViewModal.value = false
}
/** 打开新增记录弹窗,重置表单 */
function handleAdd() {
editingId.value = null
@@ -899,6 +880,17 @@ function handleBatchDelete() {
})
}
/** 进入批量选择模式(显示多选列) */
function enterSelectionMode() {
selectionMode.value = true
}
/** 退出批量选择模式并清空已选 */
function exitSelectionMode() {
selectionMode.value = false
checkedTransactionIds.value = []
}
/** 翻页事件处理 */
function handlePageChange(p: number) {
page.value = p
@@ -2062,10 +2054,15 @@ onBeforeUnmount(() => {
<span style="color: #999; font-size: 13px;">{{ t('transaction.sortLabel') }}</span>
<NSelect v-model:value="sortValue" :options="sortOptions" style="width: 170px;" />
<NDivider vertical style="margin: 0 4px;" />
<NButton v-if="!selectionMode" @click="enterSelectionMode">{{ t('transaction.batchMode') }}</NButton>
<NButton v-else @click="exitSelectionMode">{{ t('transaction.exitBatchMode') }}</NButton>
<NButton @click="handleBatchOpen">{{ t('transaction.batchSet') }}</NButton>
<NButton type="error" :disabled="checkedTransactionIds.length === 0" @click="handleBatchDelete">
<NButton v-if="checkedTransactionIds.length > 0" type="error" @click="handleBatchDelete">
{{ t('transaction.batchDelete') }}
</NButton>
<NButton v-if="checkedTransactionIds.length >= 2" type="primary" ghost @click="handleCreateRelation">
{{ t('transaction.relation.createFromSelection') }}
</NButton>
<NButton @click="showImportModal = true">{{ t('transaction.importBill') }}</NButton>
<NButton @click="goChannelManage">{{ t('channel.manageEntry') }}</NButton>
<NButton :loading="exporting" @click="handleExport">{{ t('transaction.exportExcel') }}</NButton>
@@ -2342,28 +2339,35 @@ onBeforeUnmount(() => {
<!-- 账单导入弹窗 -->
<BillImportModal v-model:show="showImportModal" @imported="fetchData" />
<!-- 账单详情弹窗(含关系与来源图片) -->
<TransactionDetailModal
v-model:show="showDetailModal"
:transaction="detailTransaction"
@changed="fetchData"
@edit="handleDetailEdit"
/>
<!-- 勾选账单建立关系弹窗 -->
<TransactionRelationEditorModal
v-model:show="showRelationEditor"
:editing-group="null"
:initial-members="relationEditorMembers"
:anchor-transaction-id="null"
@saved="onRelationEditorSaved"
/>
<!-- 关系查看弹窗 -->
<TransactionRelationModal
v-model:show="showRelationModal"
:transaction="relationTransaction"
@changed="fetchData"
/>
<!-- 原始账单数据弹窗 -->
<NModal v-model:show="showRawModal" preset="card" :title="t('transaction.rawDataTitle')" style="width: 640px;">
<NInput :value="rawDataText" type="textarea" readonly :autosize="{ minRows: 8, maxRows: 20 }" />
</NModal>
<!-- 来源图片查看弹窗(图片记账生成的记录):默认加载缩略图,可切换原图 -->
<NModal v-model:show="showImageViewModal" preset="card" :title="t('transaction.sourceImages')" style="width: 720px; max-width: 92vw;" @after-leave="closeImageView">
<NSpin :show="imageViewLoading">
<NSpace vertical :size="12" style="min-height: 60px;">
<div v-for="item in imageViewItems" :key="item.id" class="image-view-item">
<img :src="item.url" alt="source image" style="width: 100%; max-height: 70vh; object-fit: contain; border-radius: 6px;" />
<div class="image-view-actions">
<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>
</NSpin>
</NModal>
<!-- 一键设置弹窗(作用于当前表格展示的记录) -->
<NModal v-model:show="showBatchModal" preset="card" :title="t('transaction.batchTitle')" style="width: 480px;">
<NForm>
@@ -2602,15 +2606,6 @@ onBeforeUnmount(() => {
text-decoration: line-through;
}
/* 图片识别进度:浅色卡片 + 步骤列表 */
.image-view-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.image-view-actions {
display: flex;
justify-content: flex-end;
}
.ai-progress {
background: #f7f9fc;
border: 1px solid #eef1f6;