feat: AI 图片记账、自定义交易渠道与淘宝/京东账单导入

- AI 图片记账:多图上传由视觉模型识别,SSE 实时推送进度,提取 rawData,按「类型+金额+时间」检测重复,支持缩略图与来源图片查看
- 自定义交易渠道:新增 t_channel 与渠道管理页,支持新建/改名/删除,并接入统计、导出与 AI 查询/记账/建议提示词
- AI 建议:单条建议新增交易渠道推荐(推荐已有渠道 / AI 新建渠道)
- 账单导入:新增淘宝(xlsx)、京东(CSV)解析器
- 交易:新增批量创建接口,导入时按去重键兜底跳过重复
- 运行目录统一经 AppHome 解析,图片与数据文件落盘规范化
- 前端:AI 记账方式偏好记忆、AI 按钮与输入框对齐、来源图片弹窗支持加载原图
This commit is contained in:
2026-09-21 16:21:58 +08:00
parent 96130d269e
commit 2ade60da42
73 changed files with 5024 additions and 354 deletions
+730 -26
View File
@@ -1,23 +1,27 @@
<script setup lang="ts">
// 收支记录管理组件:展示交易记录列表(分页),支持新增、编辑、删除、多条件筛选
import { ref, onMounted, computed, h, watch } from 'vue'
import { ref, onMounted, onBeforeUnmount, computed, h, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDivider, NDropdown } from 'naive-ui'
import { EyeOutline, EyeOffOutline, SparklesOutline, BulbOutline, RefreshOutline } from '@vicons/ionicons5'
import type { DataTableColumns } from 'naive-ui'
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags, getChannels, batchDeleteTransactions } from '../api/transaction'
import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi } from '../api/ai'
import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai'
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 { 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 { createChannel } from '../api/channel'
import type { AiSuggestion, AiQueryResult, AiRecordResult, AiRecordImageResult } from '../api/ai'
import { getExcludedDisplayStyle } from '../api/setting'
import { getCategoryList, createCategory } from '../api/category'
import { getTagList, createTag } from '../api/tag'
import { getChannelLabel } from '../utils/channel'
import { renderCategoryLabel } from '../utils/render'
import { useKeywordHistory } from '../composables/useKeywordHistory'
import { openSse } from '../utils/sse'
import BillImportModal from './BillImportModal.vue'
const message = useMessage() // 消息提示
const dialog = useDialog() // 对话框
const router = useRouter() // 路由
const { t } = useI18n() // 国际化
/** 交易记录数据结构 */
@@ -35,6 +39,8 @@ interface TransactionItem {
includeInStats: boolean
rawData: string | null
tags: TagOption[]
/** 来源图片ID列表(图片记账生成时存在) */
imageIds: number[]
createdAt: string
}
@@ -53,6 +59,27 @@ interface TagOption {
color: string
}
/** 图片记账结果编辑行 */
interface AiRecordEditRow {
key: number
type: string
amount: number | null
categoryId: number | null
channel: string
remark: string
transactionTime: number
includeInStats: boolean
tagIds: number[]
/** 模型提取的原始字段 JSON 文本,导入时写入交易记录的原始数据 */
rawData: string | null
/** 来源图片序号(从 1 开始) */
imageIndex: number | null
/** 来源图片ID,导入时用于关联图片 */
imageId: number | null
/** 是否疑似重复(默认不勾选,可手动勾选强制导入) */
duplicate: boolean
}
// 列表数据和分页状态
const transactions = ref<TransactionItem[]>([]) // 交易记录列表
const total = ref(0) // 总记录数
@@ -137,6 +164,10 @@ const aiNewSaving = ref(false) // 新分类保存状态
// AI 生成但尚未保存的新分类,需用户确认后再落库
const pendingCategory = ref<{ name: string; type: string; icon: string } | null>(null)
const aiTagSuggestLoading = ref(false) // 编辑弹窗内「推荐已有标签」请求状态
const aiChannelLoading = ref(false) // 编辑弹窗内「推荐交易渠道」请求状态
const aiChannelNewLoading = ref(false) // 编辑弹窗内「生成新渠道」请求状态
const aiChannelNewSaving = ref(false) // 编辑弹窗内「保存新渠道」请求状态
const pendingChannel = ref<{ name: string } | null>(null) // AI 生成的新渠道(待用户确认保存)
const aiTagNewLoading = ref(false) // 编辑弹窗内「生成新标签」请求状态
const aiTagSaving = ref(false) // 新标签保存状态
// AI 生成但尚未保存的新标签,需用户确认后再落库
@@ -159,6 +190,54 @@ const aiQueryHistory = useKeywordHistory({
const showAiRecordModal = ref(false) // AI 记账弹窗显示状态
const aiRecordText = ref('') // AI 记账输入
const aiRecording = ref(false) // AI 记账解析状态
/** AI 记账方式偏好存储键(参考邮箱页 Tab 记忆) */
const AI_RECORD_TAB_KEY = 'finance.transaction.aiRecordTab'
/** 读取上次的 AI 记账方式偏好,非法值回退文字记账 */
function loadAiRecordTab(): 'text' | 'image' {
try {
const saved = localStorage.getItem(AI_RECORD_TAB_KEY)
return saved === 'image' ? 'image' : 'text'
} catch (e) {
return 'text'
}
}
const aiRecordTab = ref<'text' | 'image'>(loadAiRecordTab()) // AI 记账方式:文字/图片(记忆用户偏好)
// 记住用户选择的 AI 记账方式
watch(aiRecordTab, (value) => {
try {
localStorage.setItem(AI_RECORD_TAB_KEY, value)
} catch (e) {
// 忽略存储异常
}
})
/** 图片记账单次最多上传的图片数量 */
const MAX_AI_RECORD_IMAGES = 5
const aiRecordFileList = ref<UploadFileInfo[]>([]) // 图片记账上传文件列表
const aiRecordFiles = ref<File[]>([]) // 待识别的图片文件(最多 MAX_AI_RECORD_IMAGES 张)
const aiRecordHint = ref('') // 图片识别补充说明
const aiRecordingImage = ref(false) // 图片识别状态
const showAiRecordResultModal = 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 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) // 批量设置的支出分类
@@ -521,6 +600,16 @@ function createColumns(): DataTableColumns<TransactionItem> {
})
},
},
{
title: t('transaction.sourceImages'), key: 'imageIds', width: 110, align: 'center',
render(row) {
if (!row.imageIds || row.imageIds.length === 0) {
return h('span', { style: { color: '#ccc' } }, '-')
}
return h(NButton, { size: 'small', quaternary: true, type: 'primary', onClick: () => openImageView(row.imageIds) },
{ default: () => t('transaction.viewImages', { count: row.imageIds.length }) })
},
},
{
title: t('transaction.column.actions'), key: 'actions', width: 240,
render(row) {
@@ -538,16 +627,21 @@ function createColumns(): DataTableColumns<TransactionItem> {
const columns = computed(() => createColumns()) // 表格列配置(随语言切换响应式更新)
/** 从后端加载交易渠道列表,供筛选与编辑表单使用 */
/** 从后端加载交易渠道列表(内置 + 自定义),供筛选与编辑表单使用 */
async function loadChannels() {
try {
const res: any = await getChannels()
channelCodes.value = res.data || []
channelCodes.value = (res.data || []).map((item: any) => item.code)
} catch (e) {
// 静默处理,交易渠道下拉保持为空
}
}
/** 跳转到交易渠道管理页 */
function goChannelManage() {
router.push('/channels')
}
/** 加载「不计入统计」记录展示样式偏好,失败时保持默认删除线 */
async function loadExcludedDisplayStyle() {
try {
@@ -631,12 +725,75 @@ function handleViewRaw(row: TransactionItem) {
showRawModal.value = true
}
/** 查看图片记账结果行的原始数据(格式化为缩进 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
}
/** 从接口响应中提取图片 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
form.value = { categoryId: null, type: 'EXPENSE', amount: null, channel: '', remark: '', transactionTime: Date.now(), includeInStats: true, tagIds: [] }
pendingCategory.value = null
pendingTag.value = null
pendingChannel.value = null
showModal.value = true
}
@@ -655,6 +812,7 @@ function handleEdit(row: TransactionItem) {
}
pendingCategory.value = null
pendingTag.value = null
pendingChannel.value = null
showModal.value = true
}
@@ -939,6 +1097,94 @@ async function handleAiTagSuggest() {
}
}
/** 编辑弹窗内 AI 推荐交易渠道:基于表单当前内容,仅填充渠道,不覆盖分类与标签 */
async function handleAiChannelSuggest() {
aiChannelLoading.value = true
try {
const res: any = await suggestOneByAi({
type: form.value.type,
amount: form.value.amount,
channel: form.value.channel || undefined,
remark: form.value.remark || undefined,
})
const suggestion = res.data
if (!suggestion || !suggestion.channel) {
message.warning(t('ai.noChannelSuggestion'))
return
}
form.value.channel = suggestion.channel
message.success(t('ai.channelApplied'))
} catch (e: any) {
message.error(e.message || t('ai.suggestFailed'))
} finally {
aiChannelLoading.value = false
}
}
/** 拼接用于生成渠道的账单描述 */
function buildChannelPrompt() {
const typeLabel = form.value.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')
const parts = [`这是一笔${typeLabel}记录`]
if (form.value.remark) parts.push(`备注:${form.value.remark}`)
if (form.value.amount) parts.push(`金额:${form.value.amount}`)
if (form.value.channel) parts.push(`当前渠道:${getChannelLabel(form.value.channel)}`)
parts.push('请生成一个适合这类交易的记账渠道名称')
return parts.join(',')
}
/** 编辑弹窗内根据账单信息生成新渠道(暂不保存,等待用户确认) */
async function handleAiNewChannel() {
if (!form.value.remark && !form.value.amount) {
message.warning(t('ai.newChannel.needInfo'))
return
}
aiChannelNewLoading.value = true
try {
const res: any = await parseChannelByAi(buildChannelPrompt())
const data = res.data
if (!data || !data.name) {
message.warning(t('ai.newChannel.failed'))
return
}
pendingChannel.value = { name: data.name }
} catch (e: any) {
message.error(e.message || t('ai.newChannel.failed'))
} finally {
aiChannelNewLoading.value = false
}
}
/** 确认保存 AI 生成的新渠道并选用 */
async function handleSaveNewChannel() {
const pending = pendingChannel.value
if (!pending) {
return
}
// 已存在同名渠道时直接选用,避免重复创建
const existing = channelOptions.value.find((option) => option.label === pending.name)
if (existing) {
form.value.channel = existing.value
message.info(t('ai.newChannel.exists'))
pendingChannel.value = null
return
}
aiChannelNewSaving.value = true
try {
const res: any = await createChannel(pending.name)
await loadChannels()
const created = res?.data
if (created?.code) {
form.value.channel = created.code
}
message.success(t('ai.newChannel.saved'))
pendingChannel.value = null
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
aiChannelNewSaving.value = false
}
}
/** 编辑弹窗内根据账单信息生成新标签(暂不保存,等待用户确认) */
async function handleAiNewTag() {
if (!form.value.remark && !form.value.amount) {
@@ -1062,7 +1308,13 @@ function handleAiRecordOpen() {
message.warning(t('ai.needConfig'))
return
}
abortAiRecordStream()
aiRecordText.value = ''
// 沿用上次的记账方式偏好(文字/图片),不强制重置
aiRecordFileList.value = []
aiRecordFiles.value = []
aiRecordHint.value = ''
aiRecordProgress.value = []
showAiRecordModal.value = true
}
@@ -1100,6 +1352,281 @@ async function handleAiRecordParse() {
}
}
/** 图片记账:设置待识别的图片文件列表(最多 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 (!showAiRecordModal.value) 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)
showAiRecordModal.value = false
showAiRecordResultModal.value = true
}
/** 图片记账:中断进行中的识别连接 */
function abortAiRecordStream() {
if (aiRecordStreamController) {
aiRecordStreamController.abort()
aiRecordStreamController = null
}
}
/** 将时间格式化为 HH:mm:ss,用于进度日志前缀 */
function formatProgressTime(date: Date): string {
const pad = (value: number) => String(value).padStart(2, '0')
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
/** 图片记账:通过 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
},
})
}
/** 关闭 AI 记账弹窗,中断进行中的识别 */
function handleAiRecordCancel() {
abortAiRecordStream()
aiRecordingImage.value = false
showAiRecordModal.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 }))
showAiRecordResultModal.value = false
await fetchData()
} catch (e: any) {
message.error(e.message || t('ai.record.importFailed'))
} finally {
aiRecordImporting.value = false
}
}
/** 图片记账结果表格列(类型、金额、分类、时间、备注可编辑) */
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: categories.value.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') })
: '-'),
},
])
/** 打开 AI 智能查询弹窗 */
function handleAiQueryOpen() {
if (!aiEnabled.value) {
@@ -1517,6 +2044,12 @@ onMounted(() => {
fetchTags()
loadChannels()
fetchData()
// 监听剪贴板粘贴,支持在图片记账弹窗中直接粘贴图片
window.addEventListener('paste', handleAiRecordPaste)
})
onBeforeUnmount(() => {
window.removeEventListener('paste', handleAiRecordPaste)
})
</script>
@@ -1534,6 +2067,7 @@ onMounted(() => {
{{ t('transaction.batchDelete') }}
</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>
<NButton :loading="loading" @click="fetchData">
<template #icon>
@@ -1739,10 +2273,10 @@ onMounted(() => {
<NSelect v-model:value="form.type" :options="typeOptions" />
</NFormItem>
<NFormItem :label="t('transaction.column.category')">
<div style="display: flex; gap: 8px; width: 100%;">
<div style="display: flex; align-items: center; gap: 8px; width: 100%;">
<NSelect v-model:value="form.categoryId" :options="categories.filter(c => c.type === form.type)" :render-label="renderCategoryLabel" :placeholder="t('transaction.selectCategoryPlaceholder')" filterable style="flex: 1;" />
<NButton v-if="aiEnabled" size="small" :loading="aiFormLoading" @click="handleAiSuggestOne">{{ t('ai.pickButton') }}</NButton>
<NButton v-if="aiEnabled" size="small" :loading="aiNewLoading" @click="handleAiNewCategory">{{ t('ai.newCategory.button') }}</NButton>
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiFormLoading" @click="handleAiSuggestOne">{{ t('ai.pickButton') }}</NButton>
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiNewLoading" @click="handleAiNewCategory">{{ t('ai.newCategory.button') }}</NButton>
</div>
</NFormItem>
<!-- AI 生成的新分类确认条:用户确认后才保存 -->
@@ -1756,8 +2290,19 @@ onMounted(() => {
<NInputNumber v-model:value="form.amount" :min="0.01" :precision="2" style="width: 100%;" :placeholder="t('transaction.amountPlaceholder')" />
</NFormItem>
<NFormItem :label="t('transaction.column.channel')">
<NSelect v-model:value="form.channel" :options="channelOptions" clearable :placeholder="t('transaction.channelPlaceholder')" />
<div style="display: flex; align-items: center; gap: 8px; width: 100%;">
<NSelect v-model:value="form.channel" :options="channelOptions" clearable :placeholder="t('transaction.channelPlaceholder')" style="flex: 1;" />
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiChannelLoading" @click="handleAiChannelSuggest">{{ t('ai.pickButton') }}</NButton>
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiChannelNewLoading" @click="handleAiNewChannel">{{ t('ai.newChannel.button') }}</NButton>
</div>
</NFormItem>
<!-- AI 生成的新渠道确认条:用户确认后才保存 -->
<div v-if="pendingChannel" style="display: flex; align-items: center; gap: 8px; margin-bottom: 18px; padding: 8px 12px; background: #f7f7fa; border-radius: 4px; font-size: 13px;">
<span>{{ t('ai.newChannel.hint') }}</span>
<span style="font-weight: 600;">{{ pendingChannel.name }}</span>
<NButton size="tiny" type="primary" :loading="aiChannelNewSaving" @click="handleSaveNewChannel">{{ t('ai.newChannel.save') }}</NButton>
<NButton size="tiny" @click="pendingChannel = null">{{ t('common.cancel') }}</NButton>
</div>
<NFormItem :label="t('transaction.column.transactionTime')">
<NDatePicker v-model:value="form.transactionTime" type="datetime" style="width: 100%;" />
</NFormItem>
@@ -1765,7 +2310,7 @@ onMounted(() => {
<NInput v-model:value="form.remark" type="textarea" :placeholder="t('transaction.remarkPlaceholder')" />
</NFormItem>
<NFormItem :label="t('transaction.column.tags')">
<div style="display: flex; gap: 8px; width: 100%;">
<div style="display: flex; align-items: center; gap: 8px; width: 100%;">
<NSelect
v-model:value="form.tagIds"
:options="tagFilterOptions"
@@ -1775,8 +2320,8 @@ onMounted(() => {
:placeholder="t('transaction.tagPlaceholder')"
style="flex: 1;"
/>
<NButton v-if="aiEnabled" size="small" :loading="aiTagSuggestLoading" @click="handleAiTagSuggest">{{ t('ai.pickButton') }}</NButton>
<NButton v-if="aiEnabled" size="small" :loading="aiTagNewLoading" @click="handleAiNewTag">{{ t('ai.newTag.button') }}</NButton>
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiTagSuggestLoading" @click="handleAiTagSuggest">{{ t('ai.pickButton') }}</NButton>
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiTagNewLoading" @click="handleAiNewTag">{{ t('ai.newTag.button') }}</NButton>
</div>
</NFormItem>
<!-- AI 生成的新标签确认条:用户确认后才保存 -->
@@ -1802,6 +2347,23 @@ onMounted(() => {
<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>
@@ -1871,19 +2433,96 @@ onMounted(() => {
</NSpace>
</NModal>
<!-- AI 智能记账弹窗 -->
<NModal v-if="aiEnabled" v-model:show="showAiRecordModal" preset="card" :title="t('ai.record.title')" style="width: 520px; max-width: 92vw;">
<!-- AI 智能记账弹窗(文字 / 图片) -->
<NModal v-if="aiEnabled" v-model:show="showAiRecordModal" 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="showAiRecordModal = 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-if="aiEnabled" v-model:show="showAiRecordResultModal" preset="card" :title="t('ai.record.resultTitle')" style="width: 960px; max-width: 96vw;">
<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')"
<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"
size="small"
:bordered="false"
/>
<NSpace justify="end">
<NButton @click="showAiRecordModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="aiRecording" @click="handleAiRecordParse">{{ t('ai.record.parse') }}</NButton>
<NButton @click="showAiRecordResultModal = 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>
@@ -1962,4 +2601,69 @@ onMounted(() => {
:deep(.excluded-row .remark-text) {
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;
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>