refactor: 拆分交易记录前后端巨型文件并优化批量与导出性能

将 TransactionServiceImpl(922 行)与 TransactionList.vue(2811 行)按职责拆分为多个协作组件,对外接口与既有行为保持兼容,并在拆分后的组件中修复批量创建 N+1 查询与导出全量加载的内存风险。

变更内容:

- 后端拆分:TransactionServiceImpl 作为门面保留 TransactionService 契约,抽出 TransactionQuerySupport(查询条件构建)、TransactionVoEnricher(视图富化)、TransactionTagWriter(标签写入)、TransactionBatchService(批量操作)与 exporter.TransactionExportAssembler(导出组装)
- 前端拆分:TransactionList.vue 抽出 AiRecordModal、AiSuggestModal、TransactionBatchModal 三个弹窗组件与 utils/datetime 工具,并将本地 AI 启用状态迁移到共享 useAiEnabled
- 性能:batchCreate 预加载分类消除循环内 N+1 并改为批量写入标签关联;export 增加 1 万条上限保护,超限提示缩小筛选范围
This commit is contained in:
2026-10-09 14:29:00 +08:00
parent 4735f0bf85
commit 863e221220
11 changed files with 1867 additions and 1439 deletions
+562
View File
@@ -0,0 +1,562 @@
<script setup lang="ts">
// AI 智能记账弹窗:支持文字记账与图片记账(SSE 实时进度),识别结果可编辑后批量导入
import { ref, computed, watch, onBeforeUnmount, h } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage, NButton, NSpace, NDataTable, NTag, NModal, NInput, NInputNumber, NSelect, NDatePicker, NAlert, NUpload, NUploadDragger, NTabs, NTabPane, NIcon } from 'naive-ui'
import { SparklesOutline, RefreshOutline, CloudUploadOutline, CheckmarkCircleOutline } from '@vicons/ionicons5'
import type { DataTableColumns, UploadFileInfo } from 'naive-ui'
import { parseRecordByAi } from '../api/ai'
import type { AiRecordResult, AiRecordImageResult } from '../api/ai'
import { batchCreateTransactions } from '../api/transaction'
import { renderCategoryLabel } from '../utils/render'
import { openSse } from '../utils/sse'
import { useStringPreference } from '../composables/usePreference'
import { STORAGE_KEYS } from '../utils/storageKeys'
import { formatLocalDateTime, formatProgressTime } from '../utils/datetime'
const props = defineProps<{
show: boolean
categories: { label: string; value: number; type: string; icon: string }[]
}>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
(e: 'imported'): void
(e: 'parsed', record: AiRecordResult): void
}>()
const message = useMessage()
const { t } = useI18n()
/** 图片记账结果编辑行 */
interface AiRecordEditRow {
key: number
type: string
amount: number | null
categoryId: number | null
channel: string
remark: string
transactionTime: number
includeInStats: boolean
tagIds: number[]
rawData: string | null
imageIndex: number | null
imageId: number | null
duplicate: boolean
}
/** 图片记账单次最多上传的图片数量 */
const MAX_AI_RECORD_IMAGES = 5
const showModel = computed({
get: () => props.show,
set: (value: boolean) => emit('update:show', value),
})
const showResultModal = ref(false) // 图片记账结果弹窗显示状态
const showRawModal = ref(false) // 原始账单数据弹窗显示状态
const rawDataText = ref('') // 格式化后的原始账单数据文本
const aiRecordText = ref('') // 文字记账输入
const aiRecording = ref(false) // 文字记账解析状态
const aiRecordTab = useStringPreference(STORAGE_KEYS.transactionAiRecordTab, 'text', {
validate: (value) => value === 'text' || value === 'image',
})
const aiRecordFileList = ref<UploadFileInfo[]>([]) // 上传文件列表
const aiRecordFiles = ref<File[]>([]) // 待识别的图片文件
const aiRecordHint = ref('') // 图片识别补充说明
const aiRecordingImage = ref(false) // 图片识别状态
const aiRecordSummary = ref('') // 图片识别摘要
const aiRecordRows = ref<AiRecordEditRow[]>([]) // 图片识别结果行(可编辑)
const checkedAiRecordKeys = ref<number[]>([]) // 结果勾选的行
const aiRecordImporting = ref(false) // 导入状态
const aiRecordProgress = ref<{ time: string; text: string }[]>([]) // 识别进度日志
let aiRecordStreamController: AbortController | null = null // SSE 连接控制器
const typeOptions = computed(() => [
{ label: t('common.types.expense'), value: 'EXPENSE' },
{ label: t('common.types.income'), value: 'INCOME' },
])
/** 打开弹窗时重置状态并中断上次识别 */
watch(() => props.show, (visible) => {
if (!visible) return
abortAiRecordStream()
aiRecordText.value = ''
aiRecordFileList.value = []
aiRecordFiles.value = []
aiRecordHint.value = ''
aiRecordProgress.value = []
})
/** 解析自然语言记账并预填新增表单 */
async function handleAiRecordParse() {
const text = aiRecordText.value.trim()
if (!text) {
message.warning(t('ai.record.emptyInput'))
return
}
aiRecording.value = true
try {
const res: any = await parseRecordByAi(text)
const r: AiRecordResult = res.data
emit('parsed', r)
showModel.value = false
} catch (e: any) {
message.error(e.message || t('ai.record.failed'))
} finally {
aiRecording.value = false
}
}
/** 设置待识别的图片文件列表(最多 MAX_AI_RECORD_IMAGES 张) */
function setAiRecordFiles(files: File[]) {
const limited = files.slice(0, MAX_AI_RECORD_IMAGES)
aiRecordFiles.value = limited
aiRecordFileList.value = limited.map((file, index) => ({
id: `${Date.now()}-${index}`,
name: file.name || `clipboard-${index + 1}.png`,
status: 'finished',
file,
} as UploadFileInfo))
}
/** 处理上传文件变化,最多保留 MAX_AI_RECORD_IMAGES 张 */
function handleAiRecordFileChange(options: { fileList: UploadFileInfo[] }) {
const files = options.fileList
.map((item) => item.file)
.filter((file): file is File => !!file)
setAiRecordFiles(files)
}
/** 追加一张图片(用于多次粘贴累加) */
function appendAiRecordFile(file: File) {
if (aiRecordFiles.value.length >= MAX_AI_RECORD_IMAGES) {
message.warning(t('ai.record.maxImages', { count: MAX_AI_RECORD_IMAGES }))
return
}
setAiRecordFiles([...aiRecordFiles.value, file])
}
/** 监听粘贴事件,从剪贴板读取图片(Ctrl+V / ⌘V),支持多次粘贴累加 */
function handleAiRecordPaste(e: ClipboardEvent) {
if (!props.show) return
const dt = e.clipboardData
if (!dt) return
for (let i = 0; i < dt.items.length; i++) {
const item = dt.items[i]
if (item.kind === 'file' && item.type.startsWith('image/')) {
const file = item.getAsFile()
if (file) {
e.preventDefault()
appendAiRecordFile(file)
aiRecordTab.value = 'image'
message.success(t('ai.record.pasteSuccess'))
}
return
}
}
}
/** 应用识别结果并打开结果预览 */
function applyAiRecordImageResult(result: AiRecordImageResult) {
const records = result.records || []
if (records.length === 0) {
message.warning(t('ai.record.noRecord'))
return
}
aiRecordSummary.value = result.summary || ''
aiRecordRows.value = records.map((r, index) => ({
key: index,
type: r.type || 'EXPENSE',
amount: r.amount ?? null,
categoryId: r.categoryId ?? null,
channel: r.channel || '',
remark: r.remark || '',
transactionTime: r.transactionTime ? new Date(r.transactionTime).getTime() : Date.now(),
includeInStats: r.includeInStats ?? true,
tagIds: r.tagIds || [],
rawData: r.rawData ?? null,
imageIndex: r.imageIndex ?? null,
imageId: r.imageId ?? null,
duplicate: r.duplicate ?? false,
}))
// 疑似重复的记录默认不勾选,用户可手动勾选强制导入
checkedAiRecordKeys.value = aiRecordRows.value.filter((row) => !row.duplicate).map((row) => row.key)
showModel.value = false
showResultModal.value = true
}
/** 中断进行中的识别连接 */
function abortAiRecordStream() {
if (aiRecordStreamController) {
aiRecordStreamController.abort()
aiRecordStreamController = null
}
}
/** 通过 SSE 调用视觉模型识别,实时展示进度 */
function handleAiRecordParseImage() {
if (aiRecordFiles.value.length === 0) {
message.warning(t('ai.record.emptyInput'))
return
}
abortAiRecordStream()
aiRecordProgress.value = []
aiRecordingImage.value = true
const formData = new FormData()
for (const file of aiRecordFiles.value) {
formData.append('files', file)
}
const hint = aiRecordHint.value.trim()
if (hint) formData.append('hint', hint)
aiRecordStreamController = openSse('/api/ai/record/image/stream', {
method: 'POST',
body: formData,
onEvent: (event, data) => {
if (event === 'progress') {
aiRecordProgress.value.push({ time: formatProgressTime(new Date()), text: data })
} else if (event === 'done') {
try {
applyAiRecordImageResult(JSON.parse(data) as AiRecordImageResult)
} catch (e) {
message.error(t('ai.record.imageFailed'))
}
} else if (event === 'failed') {
let reason = ''
try {
reason = JSON.parse(data)?.message || ''
} catch (e) {
reason = data
}
aiRecordProgress.value.push({
time: formatProgressTime(new Date()),
text: reason ? `${t('ai.record.imageFailed')}:${reason}` : t('ai.record.imageFailed'),
})
message.error(reason || t('ai.record.imageFailed'))
}
},
onError: (e: any) => {
message.error(e?.message || t('ai.record.imageFailed'))
},
onClose: () => {
aiRecordingImage.value = false
aiRecordStreamController = null
},
})
}
/** 关闭弹窗,中断进行中的识别 */
function handleAiRecordCancel() {
abortAiRecordStream()
aiRecordingImage.value = false
showModel.value = false
}
/** 导入勾选的结果行 */
async function handleAiRecordImport() {
const selected = aiRecordRows.value.filter((row) => checkedAiRecordKeys.value.includes(row.key))
if (selected.length === 0) {
message.warning(t('ai.record.selectAtLeastOne'))
return
}
if (selected.some((row) => !row.amount || row.amount <= 0)) {
message.warning(t('transaction.validAmount'))
return
}
aiRecordImporting.value = true
try {
const res: any = await batchCreateTransactions({
records: selected.map((row) => ({
categoryId: row.categoryId,
type: row.type,
amount: row.amount!,
channel: row.channel || undefined,
remark: row.remark || undefined,
transactionTime: formatLocalDateTime(row.transactionTime),
includeInStats: row.includeInStats,
tagIds: row.tagIds,
rawData: row.rawData || undefined,
imageId: row.imageId ?? undefined,
})),
})
message.success(t('ai.record.importSuccess', { count: res.data ?? selected.length }))
showResultModal.value = false
emit('imported')
} catch (e: any) {
message.error(e.message || t('ai.record.importFailed'))
} finally {
aiRecordImporting.value = false
}
}
/** 查看结果行的原始数据(格式化为缩进 JSON) */
function handleViewAiRecordRaw(row: AiRecordEditRow) {
if (!row.rawData) return
try {
rawDataText.value = JSON.stringify(JSON.parse(row.rawData), null, 2)
} catch (e) {
rawDataText.value = row.rawData
}
showRawModal.value = true
}
/** 图片记账结果表格列(类型、金额、分类、时间、备注可编辑) */
const aiRecordColumns = computed<DataTableColumns<AiRecordEditRow>>(() => [
{ type: 'selection' },
{
title: t('ai.record.col.source'),
key: 'imageIndex',
width: 80,
render: (row) => (row.imageIndex ? t('ai.record.sourceImageLabel', { index: row.imageIndex }) : '-'),
},
{
title: t('ai.record.col.duplicate'),
key: 'duplicate',
width: 90,
render: (row) => (row.duplicate
? h(NTag, { type: 'warning', size: 'small' }, { default: () => t('ai.record.duplicate') })
: '-'),
},
{
title: t('ai.record.col.type'),
key: 'type',
width: 100,
render: (row) => h(NSelect, {
value: row.type,
options: typeOptions.value,
size: 'small',
onUpdateValue: (value: string) => { row.type = value; row.categoryId = null },
}),
},
{
title: t('ai.record.col.amount'),
key: 'amount',
width: 120,
render: (row) => h(NInputNumber, {
value: row.amount,
min: 0.01,
precision: 2,
size: 'small',
onUpdateValue: (value: number | null) => { row.amount = value },
}),
},
{
title: t('ai.record.col.category'),
key: 'categoryId',
width: 170,
render: (row) => h(NSelect, {
value: row.categoryId,
options: props.categories.filter((c) => c.type === row.type),
renderLabel: renderCategoryLabel,
filterable: true,
clearable: true,
size: 'small',
onUpdateValue: (value: number | null) => { row.categoryId = value },
}),
},
{
title: t('ai.record.col.time'),
key: 'transactionTime',
width: 180,
render: (row) => h(NDatePicker, {
value: row.transactionTime,
type: 'datetime',
size: 'small',
onUpdateValue: (value: number | null) => { row.transactionTime = value ?? row.transactionTime },
}),
},
{
title: t('ai.record.col.remark'),
key: 'remark',
render: (row) => h(NInput, {
value: row.remark,
size: 'small',
onUpdateValue: (value: string) => { row.remark = value },
}),
},
{
title: t('ai.record.col.rawData'),
key: 'rawData',
width: 110,
render: (row) => (row.rawData
? h(NButton, { size: 'small', quaternary: true, type: 'primary', onClick: () => handleViewAiRecordRaw(row) },
{ default: () => t('transaction.rawData') })
: '-'),
},
])
watch(() => props.show, (visible) => {
if (visible) window.addEventListener('paste', handleAiRecordPaste)
else window.removeEventListener('paste', handleAiRecordPaste)
})
onBeforeUnmount(() => {
window.removeEventListener('paste', handleAiRecordPaste)
})
</script>
<template>
<div>
<!-- AI 智能记账弹窗(文字 / 图片) -->
<NModal v-model:show="showModel" preset="card" :title="t('ai.record.title')" style="width: 560px; max-width: 92vw;">
<NTabs v-model:value="aiRecordTab" type="line" animated>
<NTabPane name="text" :tab="t('ai.record.textTab')">
<NSpace vertical :size="12">
<NAlert type="info" :show-icon="true">{{ t('ai.record.hint') }}</NAlert>
<NInput
v-model:value="aiRecordText"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
:placeholder="t('ai.record.placeholder')"
/>
<NSpace justify="end">
<NButton @click="showModel = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="aiRecording" @click="handleAiRecordParse">{{ t('ai.record.parse') }}</NButton>
</NSpace>
</NSpace>
</NTabPane>
<NTabPane name="image" :tab="t('ai.record.imageTab')">
<NSpace vertical :size="12">
<NAlert type="info" :show-icon="true">{{ t('ai.record.imageHint') }}</NAlert>
<NUpload
:file-list="aiRecordFileList"
:default-upload="false"
:max="MAX_AI_RECORD_IMAGES"
multiple
accept="image/jpeg,image/png,image/gif,image/webp"
@change="handleAiRecordFileChange"
>
<NUploadDragger>
<NIcon size="36" :component="CloudUploadOutline" />
<div style="margin-top: 8px;">{{ t('ai.record.upload') }}</div>
<div style="margin-top: 4px; font-size: 12px; color: #999;">{{ t('ai.record.uploadTip') }}</div>
<div style="margin-top: 4px; font-size: 12px; color: #999;">{{ t('ai.record.pasteHint') }}</div>
</NUploadDragger>
</NUpload>
<NInput v-model:value="aiRecordHint" :placeholder="t('ai.record.placeholder')" />
<!-- 识别进度实时日志(SSE 推送) -->
<div v-if="aiRecordProgress.length > 0" class="ai-progress">
<div class="ai-progress-title">
<NIcon :component="SparklesOutline" />
<span>{{ t('ai.record.progressTitle') }}</span>
</div>
<div class="ai-progress-list">
<div
v-for="(item, index) in aiRecordProgress"
:key="index"
class="ai-progress-item"
:class="{ 'is-running': index === aiRecordProgress.length - 1 && aiRecordingImage }"
>
<NIcon
class="ai-progress-icon"
:component="index === aiRecordProgress.length - 1 && aiRecordingImage ? RefreshOutline : CheckmarkCircleOutline"
/>
<span class="ai-progress-time">{{ item.time }}</span>
<span class="ai-progress-text">{{ item.text }}</span>
</div>
</div>
</div>
<NSpace justify="end">
<NButton @click="handleAiRecordCancel">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="aiRecordingImage" :disabled="aiRecordFiles.length === 0 || aiRecordingImage" @click="handleAiRecordParseImage">
{{ t('ai.record.parseImage') }}
</NButton>
</NSpace>
</NSpace>
</NTabPane>
</NTabs>
</NModal>
<!-- 图片记账结果弹窗 -->
<NModal v-model:show="showResultModal" preset="card" :title="t('ai.record.resultTitle')" style="width: 960px; max-width: 96vw;">
<NSpace vertical :size="12">
<NAlert type="info" :show-icon="true">
{{ aiRecordSummary || t('ai.record.resultHint') }}
</NAlert>
<NDataTable
v-model:checked-row-keys="checkedAiRecordKeys"
:columns="aiRecordColumns"
:data="aiRecordRows"
:row-key="(row) => row.key"
:max-height="420"
:scroll-x="900"
size="small"
:bordered="false"
/>
<NSpace justify="end">
<NButton @click="showResultModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="aiRecordImporting" :disabled="checkedAiRecordKeys.length === 0" @click="handleAiRecordImport">
{{ t('ai.record.importSelected', { count: checkedAiRecordKeys.length }) }}
</NButton>
</NSpace>
</NSpace>
</NModal>
<!-- 原始账单数据弹窗 -->
<NModal v-model:show="showRawModal" preset="card" :title="t('transaction.rawDataTitle')" style="width: 640px; max-width: 92vw;">
<NInput :value="rawDataText" type="textarea" readonly :autosize="{ minRows: 8, maxRows: 20 }" />
</NModal>
</div>
</template>
<style scoped>
/* 图片识别进度:浅色卡片 + 步骤列表 */
.ai-progress {
background: #f7f9fc;
border: 1px solid #eef1f6;
border-radius: 8px;
padding: 10px 12px;
}
.ai-progress-title {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
color: #666;
}
.ai-progress-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 180px;
overflow-y: auto;
}
.ai-progress-item {
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 13px;
line-height: 1.5;
color: #555;
}
.ai-progress-icon {
margin-top: 2px;
flex-shrink: 0;
color: #18a058;
}
/* 最新一条处理中时用蓝色旋转图标 */
.ai-progress-item.is-running .ai-progress-icon {
color: #2080f0;
animation: ai-progress-spin 1s linear infinite;
}
.ai-progress-time {
flex-shrink: 0;
margin-top: 1px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
color: #a0a4ab;
}
.ai-progress-text {
word-break: break-all;
}
@keyframes ai-progress-spin {
to {
transform: rotate(360deg);
}
}
</style>
+235
View File
@@ -0,0 +1,235 @@
<script setup lang="ts">
// AI 建议弹窗:展示批量分类/标签建议,勾选后一键应用(含新建分类/标签确认)
import { ref, computed, watch, h } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NAlert } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { applyAiSuggestions } from '../api/ai'
import type { AiSuggestion } from '../api/ai'
/** 建议表格展示所需的交易记录精简字段 */
interface TransactionLite {
id: number
transactionTime: string
type: string
amount: number
remark: string
}
const props = defineProps<{
show: boolean
suggestions: AiSuggestion[]
transactions: TransactionLite[]
scope: string
}>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
(e: 'applied'): void
}>()
const message = useMessage()
const dialog = useDialog()
const { t } = useI18n()
const showModel = computed({
get: () => props.show,
set: (value: boolean) => emit('update:show', value),
})
const checkedAiRowKeys = ref<number[]>([]) // 已勾选建议的代表记录ID
const aiApplying = ref(false) // 应用状态
// 打开时默认全选,用户可取消不需要的建议
watch(() => props.show, (visible) => {
if (visible) {
checkedAiRowKeys.value = props.suggestions.map((s) => s.transactionId)
}
})
/** 建议行:合并代表交易记录信息用于展示 */
const aiRows = computed(() =>
props.suggestions.map((s) => ({
...s,
transaction: props.transactions.find((tx) => tx.id === s.transactionId) || null,
})),
)
/** 已勾选建议覆盖的记录总数(聚合组按组内记录数累加) */
const aiTotalCount = computed(() => {
const selected = new Set(checkedAiRowKeys.value)
return props.suggestions
.filter((s) => selected.has(s.transactionId))
.reduce((sum, s) => sum + (s.transactionIds?.length || 1), 0)
})
/** 勾选建议中需要新建的分类名称(去重) */
const aiNewCategoryNames = computed(() => {
const selected = new Set(checkedAiRowKeys.value)
const names = new Set<string>()
props.suggestions.forEach((s) => {
if (selected.has(s.transactionId) && s.newCategoryName) {
names.add(s.newCategoryName)
}
})
return Array.from(names)
})
/** 勾选建议中需要新建的标签名称(去重) */
const aiNewTagNames = computed(() => {
const selected = new Set(checkedAiRowKeys.value)
const names = new Set<string>()
props.suggestions.forEach((s) => {
if (selected.has(s.transactionId) && s.newTags) {
s.newTags.forEach((tag) => names.add(tag.name))
}
})
return Array.from(names)
})
/** 渲染建议的分类单元格,新建分类带「新建」标记 */
function renderCategoryCell(row: AiSuggestion) {
if (row.newCategoryName) {
return h(NSpace, { size: 4, align: 'center', wrap: false }, {
default: () => [
h(NTag, { size: 'small', type: 'info' }, { default: () => t('ai.newBadge') }),
h('span', null, `${row.newCategoryIcon || ''}${row.newCategoryName}`),
],
})
}
return row.categoryName || '-'
}
/** 渲染建议的标签单元格,新建标签带「新建」标记并使用建议颜色 */
function renderTagsCell(row: AiSuggestion) {
const items: any[] = []
if (row.tagNames && row.tagNames.length > 0) {
items.push(...row.tagNames.map((name) => h(NTag, { size: 'small' }, { default: () => name })))
}
if (row.newTags && row.newTags.length > 0) {
items.push(...row.newTags.map((tag) =>
h(NTag, { size: 'small', color: { color: tag.color, textColor: '#fff' } }, {
default: () => h(NSpace, { size: 2, align: 'center', wrap: false }, {
default: () => [
h('span', null, t('ai.newBadge')),
h('span', null, tag.name),
],
}),
}),
))
}
return items.length > 0 ? h(NSpace, { size: 4, wrap: true }, { default: () => items }) : '-'
}
/** 建议表格列定义(随语言与建议范围响应式更新) */
const aiColumns = computed<DataTableColumns<any>>(() => {
const columns: DataTableColumns<any> = [
{ type: 'selection' },
{
title: t('ai.column.record'), key: 'record', minWidth: 200,
render: (row) => {
const tx = row.transaction
if (!tx) return `#${row.transactionId}`
const time = tx.transactionTime?.replace('T', ' ').substring(0, 16) || '-'
const sign = tx.type === 'INCOME' ? '+' : '-'
return h('div', null, [
h('div', null, `${time} ${sign}${tx.amount.toFixed(2)}`),
h('div', { style: { color: '#999', fontSize: '12px' } }, tx.remark || '-'),
])
},
},
{ title: t('ai.column.count'), key: 'count', width: 90, render: (row) => row.count ?? 1 },
]
if (props.scope !== 'TAG') {
columns.push({
title: t('ai.column.category'), key: 'category', minWidth: 150,
render: (row) => renderCategoryCell(row),
})
}
if (props.scope !== 'CATEGORY') {
columns.push({
title: t('ai.column.tags'), key: 'tags', minWidth: 180,
render: (row) => renderTagsCell(row),
})
}
columns.push({ title: t('ai.column.reason'), key: 'reason', minWidth: 180, ellipsis: { tooltip: true } })
return columns
})
/** 一键应用全部 AI 建议:覆盖分类并追加标签 */
async function handleApply() {
const selected = new Set(checkedAiRowKeys.value)
const chosen = props.suggestions.filter((s) => selected.has(s.transactionId))
if (chosen.length === 0) {
message.warning(t('ai.applyNone'))
return
}
// 勾选中包含新建分类/标签时,先经用户确认再落库
if (aiNewCategoryNames.value.length > 0 || aiNewTagNames.value.length > 0) {
const confirmed = await new Promise<boolean>((resolve) => {
dialog.warning({
title: t('ai.applyNewTitle'),
content: t('ai.applyNewConfirm', {
categories: aiNewCategoryNames.value.length,
tags: aiNewTagNames.value.length,
}),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
onPositiveClick: () => resolve(true),
onNegativeClick: () => resolve(false),
onClose: () => resolve(false),
})
})
if (!confirmed) {
return
}
}
aiApplying.value = true
try {
// 仅提交勾选的建议,聚合建议按组展开为逐条记录
const payload = chosen.flatMap((s) => {
const ids = s.transactionIds?.length ? s.transactionIds : [s.transactionId]
return ids.map((id) => ({
transactionId: id,
categoryId: s.categoryId,
newCategoryName: s.newCategoryName,
newCategoryIcon: s.newCategoryIcon,
tagIds: s.tagIds || [],
newTags: s.newTags || [],
}))
})
const res: any = await applyAiSuggestions(payload)
message.success(t('ai.applySuccess', { count: res.data }))
showModel.value = false
emit('applied')
} catch (e: any) {
message.error(e.message || t('ai.applyFailed'))
} finally {
aiApplying.value = false
}
}
</script>
<template>
<NModal v-model:show="showModel" preset="card" :title="t('ai.suggestTitle')" style="width: 860px; max-width: 92vw;">
<NSpace vertical :size="12">
<NAlert type="warning" :show-icon="true">{{ t('ai.applyHint') }}</NAlert>
<NDataTable
v-model:checked-row-keys="checkedAiRowKeys"
:columns="aiColumns"
:data="aiRows"
:max-height="420"
:scroll-x="880"
:row-key="(row) => row.transactionId"
size="small"
:bordered="false"
/>
<NSpace justify="end">
<NButton @click="showModel = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="aiApplying" :disabled="checkedAiRowKeys.length === 0" @click="handleApply">
{{ t('ai.applyAll', { count: aiTotalCount }) }}
</NButton>
</NSpace>
</NSpace>
</NModal>
</template>
@@ -0,0 +1,159 @@
<script setup lang="ts">
// 一键设置弹窗:对目标记录批量设置分类/计入统计/标签,并可触发 AI 建议
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage, NButton, NModal, NForm, NFormItem, NSelect } from 'naive-ui'
import { batchUpdateCategory, batchTags } from '../api/transaction'
import { renderCategoryLabel } from '../utils/render'
/** 批量操作目标记录的精简字段 */
interface TargetLite {
id: number
type: string
}
const props = defineProps<{
show: boolean
targets: TargetLite[]
fromSelection: boolean
categories: { label: string; value: number; type: string; icon: string }[]
tagOptions: { label: string; value: number }[]
aiEnabled: boolean
aiScope: string
aiScopeOptions: { label: string; value: string }[]
aiLoading: boolean
}>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
(e: 'update:aiScope', value: string): void
(e: 'suggest'): void
(e: 'done'): void
}>()
const message = useMessage()
const { t } = useI18n()
const showModel = computed({
get: () => props.show,
set: (value: boolean) => emit('update:show', value),
})
const batchExpenseCategoryId = ref<number | null>(null)
const batchIncomeCategoryId = ref<number | null>(null)
const batchIncludeInStats = ref<'null' | 'true' | 'false'>('null')
const batchAddTagIds = ref<number[]>([])
const batchRemoveTagIds = ref<number[]>([])
const batching = ref(false)
const batchTargetIds = computed(() => props.targets.map((tx) => tx.id))
const hasIncomeRecords = computed(() => props.targets.some((tx) => tx.type === 'INCOME'))
const hasExpenseRecords = computed(() => props.targets.some((tx) => tx.type === 'EXPENSE'))
const batchStatsOptions = computed(() => [
{ label: t('transaction.batchStats.keep'), value: 'null' },
{ label: t('transaction.batchStats.include'), value: 'true' },
{ label: t('transaction.batchStats.exclude'), value: 'false' },
])
// 打开时重置表单
watch(() => props.show, (visible) => {
if (!visible) return
batchExpenseCategoryId.value = null
batchIncomeCategoryId.value = null
batchIncludeInStats.value = 'null'
batchAddTagIds.value = []
batchRemoveTagIds.value = []
})
/** 提交批量设置 */
async function handleSubmit() {
const hasCategoryOrStats = !!(batchExpenseCategoryId.value || batchIncomeCategoryId.value || batchIncludeInStats.value !== 'null')
const hasTags = batchAddTagIds.value.length > 0 || batchRemoveTagIds.value.length > 0
if (!hasCategoryOrStats && !hasTags) {
message.warning(t('transaction.batchNothingSelected'))
return
}
batching.value = true
try {
const ids = batchTargetIds.value
if (hasCategoryOrStats) {
await batchUpdateCategory({
ids,
expenseCategoryId: batchExpenseCategoryId.value,
incomeCategoryId: batchIncomeCategoryId.value,
includeInStats: batchIncludeInStats.value === 'true' ? true : batchIncludeInStats.value === 'false' ? false : null,
})
}
if (hasTags) {
await batchTags({
ids,
addTagIds: batchAddTagIds.value,
removeTagIds: batchRemoveTagIds.value,
})
}
message.success(t('transaction.batchUpdated', { count: ids.length }))
showModel.value = false
emit('done')
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
batching.value = false
}
}
</script>
<template>
<NModal v-model:show="showModel" preset="card" :title="t('transaction.batchTitle')" style="width: 480px; max-width: 92vw;">
<NForm>
<NFormItem :label="t('transaction.batchScope')">
<span style="color: #999; font-size: 12px;">
{{ props.fromSelection
? t('transaction.batchScopeHintSelected', { count: props.targets.length })
: t('transaction.batchScopeHintPage', { count: props.targets.length }) }}
</span>
</NFormItem>
<NFormItem v-if="props.aiEnabled" :label="t('ai.suggestButton')">
<div style="display: flex; gap: 8px; width: 100%;">
<NSelect
:value="props.aiScope"
:options="props.aiScopeOptions"
style="flex: 1;"
@update:value="(v: string) => emit('update:aiScope', v)"
/>
<NButton :loading="props.aiLoading" @click="emit('suggest')">{{ t('ai.suggestButton') }}</NButton>
</div>
</NFormItem>
<NFormItem v-if="hasExpenseRecords" :label="t('transaction.expenseCategory')">
<NSelect
v-model:value="batchExpenseCategoryId"
:options="props.categories.filter(c => c.type === 'EXPENSE')"
:render-label="renderCategoryLabel"
filterable
clearable
:placeholder="t('transaction.notSetExpense')"
/>
</NFormItem>
<NFormItem v-if="hasIncomeRecords" :label="t('transaction.incomeCategory')">
<NSelect
v-model:value="batchIncomeCategoryId"
:options="props.categories.filter(c => c.type === 'INCOME')"
:render-label="renderCategoryLabel"
filterable
clearable
:placeholder="t('transaction.notSetIncome')"
/>
</NFormItem>
<NFormItem :label="t('transaction.includeInStats')">
<NSelect v-model:value="batchIncludeInStats" :options="batchStatsOptions" />
</NFormItem>
<NFormItem :label="t('transaction.addTag')">
<NSelect v-model:value="batchAddTagIds" :options="props.tagOptions" multiple clearable filterable :placeholder="t('transaction.notAddTag')" />
</NFormItem>
<NFormItem :label="t('transaction.removeTag')">
<NSelect v-model:value="batchRemoveTagIds" :options="props.tagOptions" multiple clearable filterable :placeholder="t('transaction.notRemoveTag')" />
</NFormItem>
<NButton type="primary" block :loading="batching" @click="handleSubmit">{{ t('common.confirm') }}</NButton>
</NForm>
</NModal>
</template>
+30
View File
@@ -0,0 +1,30 @@
// 日期时间格式化工具:统一交易记录页的时间展示与接口参数格式
/** 将时间戳格式化为查询边界(YYYY-MM-DDTHH:mm:ss),isEnd=true 时取当天 23:59:59 */
export function formatIsoBoundary(ts: number, isEnd = false): string {
const d = new Date(ts)
const pad = (n: number) => String(n).padStart(2, '0')
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const time = isEnd ? '23:59:59' : '00:00:00'
return `${date}T${time}`
}
/** 将时间戳格式化为本地日期时间(YYYY-MM-DDTHH:mm:ss),避免 UTC 转换导致时区偏移 */
export function formatLocalDateTime(ts: number): string {
const d = new Date(ts)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
/** 将时间戳格式化为 yyyy-MM-dd */
export function formatDateLabel(ts: number): string {
const d = new Date(ts)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
/** 将时间格式化为 HH:mm:ss,用于进度日志前缀 */
export function formatProgressTime(date: Date): string {
const pad = (value: number) => String(value).padStart(2, '0')
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
File diff suppressed because it is too large Load Diff