- AI 图片记账:多图上传由视觉模型识别,SSE 实时推送进度,提取 rawData,按「类型+金额+时间」检测重复,支持缩略图与来源图片查看 - 自定义交易渠道:新增 t_channel 与渠道管理页,支持新建/改名/删除,并接入统计、导出与 AI 查询/记账/建议提示词 - AI 建议:单条建议新增交易渠道推荐(推荐已有渠道 / AI 新建渠道) - 账单导入:新增淘宝(xlsx)、京东(CSV)解析器 - 交易:新增批量创建接口,导入时按去重键兜底跳过重复 - 运行目录统一经 AppHome 解析,图片与数据文件落盘规范化 - 前端:AI 记账方式偏好记忆、AI 按钮与输入框对齐、来源图片弹窗支持加载原图
2670 lines
98 KiB
Vue
2670 lines
98 KiB
Vue
<script setup lang="ts">
|
||
// 收支记录管理组件:展示交易记录列表(分页),支持新增、编辑、删除、多条件筛选
|
||
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 { 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() // 国际化
|
||
|
||
/** 交易记录数据结构 */
|
||
interface TransactionItem {
|
||
id: number
|
||
categoryId: number
|
||
categoryName: string
|
||
categoryIcon: string
|
||
type: string // INCOME 或 EXPENSE
|
||
amount: number
|
||
channel: string
|
||
remark: string
|
||
remarkHidden: boolean
|
||
transactionTime: string
|
||
includeInStats: boolean
|
||
rawData: string | null
|
||
tags: TagOption[]
|
||
/** 来源图片ID列表(图片记账生成时存在) */
|
||
imageIds: number[]
|
||
createdAt: string
|
||
}
|
||
|
||
/** 分类选项数据结构 */
|
||
interface CategoryOption {
|
||
label: string
|
||
value: number
|
||
type: string
|
||
icon: string
|
||
}
|
||
|
||
/** 标签数据结构 */
|
||
interface TagOption {
|
||
id: number
|
||
name: string
|
||
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) // 总记录数
|
||
const page = ref(1) // 当前页码
|
||
const pageSize = ref(20) // 每页条数
|
||
const sortValue = ref('transactionTime-desc') // 排序方式:排序字段-方向
|
||
const sortOptions = computed(() => [
|
||
{ label: t('transaction.sort.timeDesc'), value: 'transactionTime-desc' },
|
||
{ label: t('transaction.sort.timeAsc'), value: 'transactionTime-asc' },
|
||
{ label: t('transaction.sort.amountDesc'), value: 'amount-desc' },
|
||
{ label: t('transaction.sort.amountAsc'), value: 'amount-asc' },
|
||
])
|
||
const sortField = computed(() => sortValue.value.split('-')[0]) // 服务端排序字段
|
||
const sortOrder = computed(() => sortValue.value.split('-')[1]) // 服务端排序方向:asc/desc
|
||
const pageSizes = [10, 20, 50, 100, 200, 500] // 可选的每页条数
|
||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value))) // 总页数
|
||
// 当前页金额汇总:支出、收入与结余(含本页所有展示记录)
|
||
const pageSummary = computed(() => {
|
||
let income = 0
|
||
let expense = 0
|
||
for (const tx of transactions.value) {
|
||
const amount = Number(tx.amount) || 0
|
||
if (tx.type === 'INCOME') income += amount
|
||
else expense += amount
|
||
}
|
||
return { income, expense, balance: income - expense }
|
||
})
|
||
// 当前页不计入统计的金额汇总(仅统计 includeInStats=false 的记录)
|
||
const pageExcludedSummary = computed(() => {
|
||
let income = 0
|
||
let expense = 0
|
||
for (const tx of transactions.value) {
|
||
if (tx.includeInStats) continue
|
||
const amount = Number(tx.amount) || 0
|
||
if (tx.type === 'INCOME') income += amount
|
||
else expense += amount
|
||
}
|
||
return { income, expense }
|
||
})
|
||
const loading = ref(false) // 列表加载状态
|
||
// 「不计入统计」记录的展示样式:strikethrough-整行删除线(默认)、muted-灰色标签
|
||
const excludedDisplayStyle = ref<'strikethrough' | 'muted'>('strikethrough')
|
||
const submitting = ref(false) // 表单提交状态
|
||
const showModal = ref(false) // 新增/编辑弹窗显示状态
|
||
const showImportModal = ref(false) // 账单导入弹窗显示状态
|
||
const showBatchModal = ref(false) // 批量设置分类弹窗显示状态
|
||
const showAiModal = ref(false) // AI 建议弹窗显示状态
|
||
const aiEnabled = ref(false) // AI 建议是否启用
|
||
const aiLoading = ref(false) // AI 建议生成状态
|
||
const aiFormLoading = ref(false) // 编辑弹窗内 AI 推荐状态
|
||
/** AI 建议范围偏好存储键 */
|
||
const AI_SCOPE_KEY = 'finance.transaction.aiScope'
|
||
|
||
/** 读取上次的 AI 建议范围偏好,非法值回退 BOTH */
|
||
function loadAiScope(): string {
|
||
try {
|
||
const saved = localStorage.getItem(AI_SCOPE_KEY)
|
||
return saved && ['BOTH', 'CATEGORY', 'TAG'].includes(saved) ? saved : 'BOTH'
|
||
} catch (e) {
|
||
return 'BOTH'
|
||
}
|
||
}
|
||
|
||
const aiScope = ref(loadAiScope()) // 批量 AI 建议范围:BOTH/CATEGORY/TAG(记忆用户偏好)
|
||
// 批量 AI 建议范围选项
|
||
const aiScopeOptions = computed(() => [
|
||
{ label: t('ai.scope.both'), value: 'BOTH' },
|
||
{ label: t('ai.scope.category'), value: 'CATEGORY' },
|
||
{ label: t('ai.scope.tag'), value: 'TAG' },
|
||
])
|
||
|
||
// 记住用户选择的 AI 建议范围
|
||
watch(aiScope, (value) => {
|
||
try {
|
||
localStorage.setItem(AI_SCOPE_KEY, value)
|
||
} catch (e) {
|
||
// 忽略存储异常
|
||
}
|
||
})
|
||
const aiNewLoading = ref(false) // 编辑弹窗内 AI 生成新分类状态
|
||
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 生成但尚未保存的新标签,需用户确认后再落库
|
||
const pendingTag = ref<{ name: string; color: string } | null>(null)
|
||
const aiApplying = ref(false) // AI 建议应用状态
|
||
const aiSuggestions = ref<AiSuggestion[]>([]) // AI 建议列表
|
||
const checkedAiRowKeys = ref<number[]>([]) // AI 建议弹窗中已勾选的代表记录ID
|
||
const aiQueryText = ref('') // AI 自然语言查询输入
|
||
const aiQuerying = ref(false) // AI 查询解析状态
|
||
const aiQueryResult = ref<AiQueryResult | null>(null) // AI 查询解析结果
|
||
const showAiQueryModal = ref(false) // AI 智能查询弹窗显示状态
|
||
// AI 查询历史存储键
|
||
const AI_QUERY_HISTORY_KEY = 'finance.transaction.aiQueryHistory'
|
||
// AI 查询历史(最近使用,最新在前)
|
||
const aiQueryHistory = useKeywordHistory({
|
||
storageKey: AI_QUERY_HISTORY_KEY,
|
||
text: aiQueryText,
|
||
clearLabel: () => t('ai.query.clearHistory'),
|
||
})
|
||
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) // 批量设置的支出分类
|
||
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 hasIncomeRecords = computed(() => transactions.value.some((tx) => tx.type === 'INCOME'))
|
||
const hasExpenseRecords = computed(() => transactions.value.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' },
|
||
])
|
||
const editingId = ref<number | null>(null) // 当前编辑的记录 ID(null 表示新增)
|
||
const categories = ref<CategoryOption[]>([]) // 分类选项列表
|
||
const allTags = ref<TagOption[]>([]) // 全部标签(用于筛选、编辑、批量)
|
||
const revealedRemarkIds = ref<number[]>([]) // 临时展开查看原文的备注行 ID(不持久,刷新/翻页后重置)
|
||
const checkedTransactionIds = ref<number[]>([]) // 表格多选选中的记录ID(用于批量删除)
|
||
|
||
// 表单数据(新增/编辑共用)
|
||
const form = ref({
|
||
categoryId: null as number | null,
|
||
type: 'EXPENSE',
|
||
amount: null as number | null,
|
||
channel: '',
|
||
remark: '',
|
||
transactionTime: Date.now(),
|
||
includeInStats: true,
|
||
tagIds: [] as number[],
|
||
})
|
||
|
||
// 类型选项
|
||
const typeOptions = computed(() => [
|
||
{ label: t('common.types.expense'), value: 'EXPENSE' },
|
||
{ label: t('common.types.income'), value: 'INCOME' },
|
||
])
|
||
|
||
// 交易渠道编码列表(从后端动态获取,含账单导入来源与人工交易渠道)
|
||
const channelCodes = ref<string[]>([])
|
||
|
||
// 交易渠道选项:由后端返回的编码映射为当前语言标签
|
||
const channelOptions = computed(() =>
|
||
channelCodes.value.map((code) => ({ label: getChannelLabel(code), value: code })),
|
||
)
|
||
|
||
// 筛选条件状态
|
||
const filterType = ref<string[]>([]) // 类型筛选(多选)
|
||
const filterCategory = ref<number[]>([]) // 分类筛选(多选)
|
||
const filterChannel = ref<string[]>([]) // 交易渠道筛选(多选)
|
||
const filterDateRange = ref<[number, number] | null>(null) // 日期范围筛选(起止时间戳)
|
||
const filterIncludeInStats = ref<string | null>(null) // 计入统计筛选:null-全部,'true'-计入,'false'-不计入
|
||
const filterRemark = ref('') // 备注关键字搜索
|
||
// 备注关键词历史存储键
|
||
const REMARK_HISTORY_KEY = 'finance.transaction.remarkHistory'
|
||
// 备注关键词历史(最近输入,最新在前)
|
||
const remarkHistory = useKeywordHistory({
|
||
storageKey: REMARK_HISTORY_KEY,
|
||
text: filterRemark,
|
||
clearLabel: () => t('transaction.remarkHistoryClear'),
|
||
})
|
||
const filterMinAmount = ref<number | null>(null) // 最小金额筛选
|
||
const filterMaxAmount = ref<number | null>(null) // 最大金额筛选
|
||
const filterTag = ref<number[]>([]) // 标签筛选(多选)
|
||
const showMoreFilter = ref(false) // 更多筛选弹层显示状态
|
||
|
||
// 更多筛选中已激活的条件数量
|
||
const moreFilterCount = computed(() => {
|
||
let count = 0
|
||
if (filterTag.value.length > 0) count++
|
||
if (filterIncludeInStats.value !== null) count++
|
||
if (filterMinAmount.value !== null || filterMaxAmount.value !== null) count++
|
||
return count
|
||
})
|
||
|
||
/** 清空「更多筛选」中的条件 */
|
||
function clearMoreFilters() {
|
||
filterTag.value = []
|
||
filterIncludeInStats.value = null
|
||
filterMinAmount.value = null
|
||
filterMaxAmount.value = null
|
||
}
|
||
|
||
// 分类筛选项:固定包含「未分类」,其余分类按类型筛选联动
|
||
const categoryFilterOptions = computed(() => [
|
||
{ label: t('transaction.uncategorized'), value: 0, type: '' },
|
||
...categories.value.filter(c => filterType.value.length === 0 || filterType.value.includes(c.type)),
|
||
])
|
||
|
||
// 标签筛选项
|
||
const tagFilterOptions = computed(() => allTags.value.map((tag) => ({ label: tag.name, value: tag.id })))
|
||
|
||
// 标签筛选下拉项:额外提供「无标签」选项(仅筛选使用,编辑/批量选择不包含)
|
||
const tagFilterSelectOptions = computed(() => [
|
||
{ label: t('transaction.untagged'), value: 0 },
|
||
...tagFilterOptions.value,
|
||
])
|
||
|
||
/** 将标签筛选拆分为标签ID与无标签标记,供查询与导出复用 */
|
||
function buildTagFilter() {
|
||
const ids = filterTag.value.filter((v) => v !== 0)
|
||
return {
|
||
tagIds: ids.length > 0 ? ids : undefined,
|
||
untagged: filterTag.value.includes(0) ? true : undefined,
|
||
}
|
||
}
|
||
|
||
/** 将分类筛选拆分为分类ID与未分类标记,供查询与导出复用 */
|
||
function buildCategoryFilter() {
|
||
const ids = filterCategory.value.filter((v) => v !== 0)
|
||
return {
|
||
categoryIds: ids.length > 0 ? ids : undefined,
|
||
uncategorized: filterCategory.value.includes(0) ? true : undefined,
|
||
}
|
||
}
|
||
|
||
// 计入统计筛选下拉选项
|
||
const includeInStatsOptions = computed(() => [
|
||
{ label: t('transaction.includeFilter.yes'), value: 'true' },
|
||
{ label: t('transaction.includeFilter.no'), value: 'false' },
|
||
])
|
||
|
||
/** 筛选条件偏好存储键 */
|
||
const FILTER_PREFS_KEY = 'finance.transaction.filterPrefs'
|
||
|
||
/** 从 localStorage 读取上次的筛选条件偏好并还原 */
|
||
function loadFilterPrefs() {
|
||
try {
|
||
const raw = localStorage.getItem(FILTER_PREFS_KEY)
|
||
if (!raw) return
|
||
const p = JSON.parse(raw)
|
||
if (Array.isArray(p.filterType)) filterType.value = p.filterType
|
||
if (Array.isArray(p.filterCategory)) filterCategory.value = p.filterCategory
|
||
// 兼容旧版本以 filterPaymentMethod 存储的偏好
|
||
const savedChannel = Array.isArray(p.filterChannel) ? p.filterChannel : p.filterPaymentMethod
|
||
if (Array.isArray(savedChannel)) filterChannel.value = savedChannel
|
||
if (Array.isArray(p.filterDateRange) && p.filterDateRange.length === 2) filterDateRange.value = p.filterDateRange
|
||
if (p.filterIncludeInStats === 'true' || p.filterIncludeInStats === 'false') filterIncludeInStats.value = p.filterIncludeInStats
|
||
if (typeof p.filterRemark === 'string') filterRemark.value = p.filterRemark
|
||
if (typeof p.filterMinAmount === 'number') filterMinAmount.value = p.filterMinAmount
|
||
if (typeof p.filterMaxAmount === 'number') filterMaxAmount.value = p.filterMaxAmount
|
||
if (typeof p.pageSize === 'number' && pageSizes.includes(p.pageSize)) pageSize.value = p.pageSize
|
||
if (typeof p.sortValue === 'string' && sortOptions.value.some((o) => o.value === p.sortValue)) sortValue.value = p.sortValue
|
||
if (Array.isArray(p.filterTag)) filterTag.value = p.filterTag
|
||
} catch (e) {
|
||
// 忽略损坏的偏好数据
|
||
}
|
||
}
|
||
|
||
/** 将当前筛选条件写入 localStorage,下次进入自动还原 */
|
||
function saveFilterPrefs() {
|
||
try {
|
||
localStorage.setItem(FILTER_PREFS_KEY, JSON.stringify({
|
||
filterType: filterType.value,
|
||
filterCategory: filterCategory.value,
|
||
filterChannel: filterChannel.value,
|
||
filterDateRange: filterDateRange.value,
|
||
filterIncludeInStats: filterIncludeInStats.value,
|
||
filterRemark: filterRemark.value,
|
||
filterMinAmount: filterMinAmount.value,
|
||
filterMaxAmount: filterMaxAmount.value,
|
||
pageSize: pageSize.value,
|
||
sortValue: sortValue.value,
|
||
filterTag: filterTag.value,
|
||
}))
|
||
} catch (e) {
|
||
// 忽略写入失败
|
||
}
|
||
}
|
||
|
||
// 筛选条件变化时自动保存用户偏好
|
||
watch(
|
||
[filterType, filterCategory, filterChannel, filterDateRange, filterIncludeInStats, filterRemark, filterMinAmount, filterMaxAmount, pageSize, sortValue, filterTag],
|
||
saveFilterPrefs,
|
||
{ deep: true },
|
||
)
|
||
|
||
/** 选中历史项:填入并立即搜索 */
|
||
function handleRemarkSelect(value: string) {
|
||
if (value === remarkHistory.clearValue) {
|
||
remarkHistory.clear()
|
||
return
|
||
}
|
||
filterRemark.value = value
|
||
handleSearch()
|
||
}
|
||
|
||
/** 失焦时记录当前备注关键词 */
|
||
function handleRemarkBlur() {
|
||
remarkHistory.record()
|
||
}
|
||
|
||
/** 回车时记录关键词并搜索(阻止 NAutoComplete 内部选中建议项,保持原回车搜索语义) */
|
||
function handleRemarkKeydown(e: KeyboardEvent) {
|
||
if (e.key === 'Enter') {
|
||
e.stopPropagation()
|
||
remarkHistory.record()
|
||
handleSearch()
|
||
}
|
||
}
|
||
|
||
/** 将时间戳格式化为 ISO 格式的日期时间字符串(YYYY-MM-DDTHH:mm:ss) */
|
||
function formatTimestamp(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 转换导致时区偏移 */
|
||
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())}`
|
||
}
|
||
|
||
/** 分类已选项(多选标签)渲染:图标 + 名称 */
|
||
function renderCategoryTag({ option }: { option: any }) {
|
||
return h('span', null, [
|
||
option.icon ? h('span', { style: { marginRight: '4px' } }, option.icon) : null,
|
||
option.label,
|
||
])
|
||
}
|
||
|
||
/** 渲染备注单元格内的小图标按钮 */
|
||
function renderRemarkAction(label: string, icon: any, onClick: () => void) {
|
||
return h(NButton, {
|
||
size: 'tiny',
|
||
quaternary: true,
|
||
style: { marginLeft: '6px', flexShrink: 0 },
|
||
onClick,
|
||
}, {
|
||
icon: () => h(NIcon, { size: 14 }, { default: () => h(icon) }),
|
||
default: () => label,
|
||
})
|
||
}
|
||
|
||
/** 临时展开某行的备注原文(仅本格,不改变隐藏标记) */
|
||
function revealRemark(id: number) {
|
||
if (!revealedRemarkIds.value.includes(id)) {
|
||
revealedRemarkIds.value = [...revealedRemarkIds.value, id]
|
||
}
|
||
}
|
||
|
||
/** 收起某行临时展开的备注 */
|
||
function concealRemark(id: number) {
|
||
revealedRemarkIds.value = revealedRemarkIds.value.filter((v) => v !== id)
|
||
}
|
||
|
||
/** 持久切换某行备注的隐藏标记 */
|
||
async function setRemarkHidden(row: TransactionItem, hidden: boolean) {
|
||
try {
|
||
await toggleRemarkHidden(row.id, hidden)
|
||
row.remarkHidden = hidden
|
||
concealRemark(row.id)
|
||
message.success(hidden ? t('transaction.remarkHiddenSuccess') : t('transaction.remarkShownSuccess'))
|
||
} catch (e: any) {
|
||
message.error(e.message || t('common.operationFailed'))
|
||
}
|
||
}
|
||
|
||
/** 创建表格列配置 */
|
||
function createColumns(): DataTableColumns<TransactionItem> {
|
||
// 读取展示样式,使列配置随样式偏好变化而重新计算
|
||
const muted = excludedDisplayStyle.value === 'muted'
|
||
return [
|
||
// 多选列:用于批量操作(如批量删除)
|
||
{ type: 'selection' },
|
||
{
|
||
// 序号列:按分页计算全局序号
|
||
title: t('transaction.column.index'), key: 'index', width: 70, align: 'center',
|
||
render(_row, index) {
|
||
return (page.value - 1) * pageSize.value + index + 1
|
||
},
|
||
},
|
||
{
|
||
title: t('transaction.column.transactionTime'), key: 'transactionTime', width: 170,
|
||
render(row) {
|
||
const time = row.transactionTime?.replace('T', ' ').substring(0, 16) || '-'
|
||
const created = row.createdAt?.replace('T', ' ').substring(0, 16)
|
||
return h('span', { title: t('transaction.createdTime', { time: created || '-' }) }, time)
|
||
},
|
||
},
|
||
{
|
||
title: t('transaction.column.type'), key: 'type', width: 90,
|
||
render(row) {
|
||
// 灰色标签样式下,不计入统计的记录类型显示为「不计入」
|
||
if (muted && !row.includeInStats) {
|
||
return h(NTag, { type: 'default', size: 'small' }, { default: () => t('transaction.notIncluded') })
|
||
}
|
||
return h(NTag, { type: row.type === 'INCOME' ? 'success' : 'error', size: 'small' }, { default: () => row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense') })
|
||
},
|
||
},
|
||
{
|
||
title: t('transaction.column.category'), key: 'categoryName', width: 120,
|
||
render(row) {
|
||
const name = row.categoryName || t('transaction.uncategorized')
|
||
return row.categoryIcon ? `${row.categoryIcon} ${name}` : name
|
||
},
|
||
},
|
||
{
|
||
title: t('transaction.column.amount'), key: 'amount', width: 120,
|
||
render(row) {
|
||
// 灰色标签样式下,不计入统计的记录金额置灰且不显示正负号
|
||
if (muted && !row.includeInStats) {
|
||
return h('span', { style: { color: '#999', fontWeight: 'bold' } }, row.amount.toFixed(2))
|
||
}
|
||
return h('span', { style: { color: row.type === 'INCOME' ? '#18a058' : '#d03050', fontWeight: 'bold' } },
|
||
`${row.type === 'INCOME' ? '+' : '-'}${row.amount.toFixed(2)}`)
|
||
},
|
||
},
|
||
{
|
||
title: t('transaction.column.channel'), key: 'channel', width: 100,
|
||
render(row) { return getChannelLabel(row.channel) },
|
||
},
|
||
{
|
||
title: t('transaction.column.remark'), key: 'remark', minWidth: 220,
|
||
render(row) {
|
||
const hidden = !!row.remarkHidden
|
||
const collapsed = hidden && !revealedRemarkIds.value.includes(row.id)
|
||
const nodes: any[] = [
|
||
h('span', {
|
||
class: 'remark-text',
|
||
title: collapsed ? t('transaction.remarkHidden') : (row.remark || ''),
|
||
}, collapsed ? '******' : (row.remark || '-')),
|
||
]
|
||
if (collapsed) {
|
||
nodes.push(renderRemarkAction(t('transaction.remarkView'), EyeOutline, () => revealRemark(row.id)))
|
||
nodes.push(renderRemarkAction(t('transaction.remarkUnhide'), EyeOffOutline, () => setRemarkHidden(row, false)))
|
||
} else if (hidden) {
|
||
nodes.push(renderRemarkAction(t('transaction.remarkCollapse'), EyeOffOutline, () => concealRemark(row.id)))
|
||
nodes.push(renderRemarkAction(t('transaction.remarkUnhide'), EyeOutline, () => setRemarkHidden(row, false)))
|
||
} else {
|
||
nodes.push(renderRemarkAction(t('transaction.remarkHide'), EyeOffOutline, () => setRemarkHidden(row, true)))
|
||
}
|
||
return h('div', { class: 'remark-cell' }, nodes)
|
||
},
|
||
},
|
||
{
|
||
title: t('transaction.column.tags'), key: 'tags', minWidth: 140,
|
||
render(row) {
|
||
if (!row.tags || row.tags.length === 0) return '-'
|
||
return h(NSpace, { size: 4 }, {
|
||
default: () => row.tags.map((tag) =>
|
||
h(NTag, { size: 'small', color: { color: tag.color || '#2080f0', textColor: '#fff' } }, { default: () => tag.name })),
|
||
})
|
||
},
|
||
},
|
||
{
|
||
title: t('transaction.column.includeInStats'), key: 'includeInStats', width: 100, align: 'center',
|
||
render(row) {
|
||
return h(NSwitch, {
|
||
value: row.includeInStats,
|
||
onUpdateValue: (v: boolean) => handleToggleStats(row, v),
|
||
})
|
||
},
|
||
},
|
||
{
|
||
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) {
|
||
return h(NSpace, null, {
|
||
default: () => [
|
||
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') }),
|
||
],
|
||
})
|
||
},
|
||
},
|
||
]
|
||
}
|
||
|
||
const columns = computed(() => createColumns()) // 表格列配置(随语言切换响应式更新)
|
||
|
||
/** 从后端加载交易渠道列表(内置 + 自定义),供筛选与编辑表单使用 */
|
||
async function loadChannels() {
|
||
try {
|
||
const res: any = await getChannels()
|
||
channelCodes.value = (res.data || []).map((item: any) => item.code)
|
||
} catch (e) {
|
||
// 静默处理,交易渠道下拉保持为空
|
||
}
|
||
}
|
||
|
||
/** 跳转到交易渠道管理页 */
|
||
function goChannelManage() {
|
||
router.push('/channels')
|
||
}
|
||
|
||
/** 加载「不计入统计」记录展示样式偏好,失败时保持默认删除线 */
|
||
async function loadExcludedDisplayStyle() {
|
||
try {
|
||
const res: any = await getExcludedDisplayStyle()
|
||
if (res.data === 'muted' || res.data === 'strikethrough') {
|
||
excludedDisplayStyle.value = res.data
|
||
}
|
||
} catch (e) {
|
||
// 静默处理,保持默认样式
|
||
}
|
||
}
|
||
|
||
/** 加载分类列表 */
|
||
async function fetchCategories() {
|
||
try {
|
||
const res: any = await getCategoryList()
|
||
categories.value = res.data.map((c: any) => ({ label: c.name, value: c.id, type: c.type, icon: c.icon || '' }))
|
||
} catch (e) {
|
||
// 静默处理
|
||
}
|
||
}
|
||
|
||
/** 加载标签列表 */
|
||
async function fetchTags() {
|
||
try {
|
||
const res: any = await getTagList()
|
||
allTags.value = res.data || []
|
||
} catch (e) {
|
||
// 静默处理
|
||
}
|
||
}
|
||
|
||
/** 加载交易记录列表(分页),携带所有筛选条件 */
|
||
async function fetchData() {
|
||
loading.value = true
|
||
try {
|
||
const res: any = await getTransactionPage({
|
||
page: page.value,
|
||
size: pageSize.value,
|
||
types: filterType.value.length > 0 ? filterType.value : undefined,
|
||
...buildCategoryFilter(),
|
||
channels: filterChannel.value.length > 0 ? filterChannel.value : undefined,
|
||
startTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[0]) : undefined,
|
||
endTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[1], true) : undefined,
|
||
includeInStats: filterIncludeInStats.value === null ? undefined : filterIncludeInStats.value === 'true',
|
||
minAmount: filterMinAmount.value ?? undefined,
|
||
maxAmount: filterMaxAmount.value ?? undefined,
|
||
remark: filterRemark.value.trim() || undefined,
|
||
sortField: sortField.value,
|
||
sortOrder: sortOrder.value,
|
||
...buildTagFilter(),
|
||
})
|
||
transactions.value = res.data.records
|
||
total.value = res.data.total
|
||
revealedRemarkIds.value = []
|
||
checkedTransactionIds.value = []
|
||
} catch (e: any) {
|
||
message.error(e.message || t('common.loadFailed'))
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 排序方式变化时重置到第一页并重新查询
|
||
watch(sortValue, () => {
|
||
page.value = 1
|
||
fetchData()
|
||
})
|
||
|
||
/** 查看某条记录的原始账单数据(格式化为缩进 JSON) */
|
||
function handleViewRaw(row: TransactionItem) {
|
||
if (!row.rawData) {
|
||
message.warning(t('transaction.noRawData'))
|
||
return
|
||
}
|
||
try {
|
||
rawDataText.value = JSON.stringify(JSON.parse(row.rawData), null, 2)
|
||
} catch (e) {
|
||
rawDataText.value = row.rawData
|
||
}
|
||
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
|
||
}
|
||
|
||
/** 打开编辑记录弹窗,填充表单数据 */
|
||
function handleEdit(row: TransactionItem) {
|
||
editingId.value = row.id
|
||
form.value = {
|
||
categoryId: row.categoryId,
|
||
type: row.type,
|
||
amount: row.amount,
|
||
channel: row.channel || '',
|
||
remark: row.remark || '',
|
||
transactionTime: new Date(row.transactionTime).getTime(),
|
||
includeInStats: row.includeInStats ?? true,
|
||
tagIds: (row.tags || []).map((tag) => tag.id),
|
||
}
|
||
pendingCategory.value = null
|
||
pendingTag.value = null
|
||
pendingChannel.value = null
|
||
showModal.value = true
|
||
}
|
||
|
||
/** 提交表单:校验 -> 新增或更新 -> 刷新列表 */
|
||
async function handleSubmit() {
|
||
// 编辑已有记录时允许不设置分类,便于只修改标签等信息
|
||
if (!form.value.categoryId && !editingId.value) {
|
||
message.warning(t('transaction.selectCategory'))
|
||
return
|
||
}
|
||
if (!form.value.amount || form.value.amount <= 0) {
|
||
message.warning(t('transaction.validAmount'))
|
||
return
|
||
}
|
||
const data = {
|
||
categoryId: form.value.categoryId!,
|
||
type: form.value.type,
|
||
amount: form.value.amount!,
|
||
channel: form.value.channel || undefined,
|
||
remark: form.value.remark || undefined,
|
||
transactionTime: formatLocalDateTime(form.value.transactionTime),
|
||
includeInStats: form.value.includeInStats,
|
||
tagIds: form.value.tagIds,
|
||
}
|
||
submitting.value = true
|
||
try {
|
||
if (editingId.value) {
|
||
await updateTransaction(editingId.value, data)
|
||
message.success(t('common.updateSuccess'))
|
||
} else {
|
||
await createTransaction(data)
|
||
message.success(t('common.createSuccess'))
|
||
}
|
||
showModal.value = false
|
||
await fetchData()
|
||
} catch (e: any) {
|
||
message.error(e.message || t('common.operationFailed'))
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
/** 删除记录:弹出确认对话框,确认后执行删除并刷新列表 */
|
||
function handleDelete(id: number) {
|
||
dialog.warning({
|
||
title: t('common.confirmDelete'),
|
||
content: t('transaction.deleteConfirm'),
|
||
positiveText: t('common.delete'),
|
||
negativeText: t('common.cancel'),
|
||
onPositiveClick: async () => {
|
||
try {
|
||
await deleteTransaction(id)
|
||
message.success(t('common.deleteSuccess'))
|
||
await fetchData()
|
||
} catch (e: any) {
|
||
message.error(e.message || t('common.deleteFailed'))
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
/** 批量删除选中的记录:确认后调用接口并刷新列表 */
|
||
function handleBatchDelete() {
|
||
const count = checkedTransactionIds.value.length
|
||
if (count === 0) {
|
||
message.warning(t('transaction.batchDeleteNone'))
|
||
return
|
||
}
|
||
dialog.warning({
|
||
title: t('common.confirmDelete'),
|
||
content: t('transaction.batchDeleteConfirm', { count }),
|
||
positiveText: t('common.delete'),
|
||
negativeText: t('common.cancel'),
|
||
onPositiveClick: async () => {
|
||
try {
|
||
const res: any = await batchDeleteTransactions(checkedTransactionIds.value)
|
||
message.success(t('transaction.batchDeleted', { count: res.data }))
|
||
checkedTransactionIds.value = []
|
||
await fetchData()
|
||
} catch (e: any) {
|
||
message.error(e.message || t('common.deleteFailed'))
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
/** 翻页事件处理 */
|
||
function handlePageChange(p: number) {
|
||
page.value = p
|
||
fetchData()
|
||
}
|
||
|
||
/** 每页条数变化处理:重置到第一页并重新加载 */
|
||
function handlePageSizeChange(size: number) {
|
||
pageSize.value = size
|
||
page.value = 1
|
||
fetchData()
|
||
}
|
||
|
||
/** 执行查询:重置页码并按当前筛选条件加载数据 */
|
||
function handleSearch() {
|
||
page.value = 1
|
||
fetchData()
|
||
}
|
||
|
||
/** 重置所有筛选条件并重新查询 */
|
||
function handleReset() {
|
||
filterType.value = []
|
||
filterCategory.value = []
|
||
filterChannel.value = []
|
||
filterDateRange.value = null
|
||
filterIncludeInStats.value = null
|
||
filterRemark.value = ''
|
||
filterMinAmount.value = null
|
||
filterMaxAmount.value = null
|
||
filterTag.value = []
|
||
page.value = 1
|
||
fetchData()
|
||
}
|
||
|
||
/** 在表格中直接切换"计入统计"开关 */
|
||
async function handleToggleStats(row: TransactionItem, value: boolean) {
|
||
try {
|
||
await toggleTransactionStats(row.id, value)
|
||
row.includeInStats = value
|
||
message.success(value ? t('transaction.statsIncluded') : t('transaction.statsExcluded'))
|
||
} catch (e: any) {
|
||
message.error(e.message || t('common.operationFailed'))
|
||
}
|
||
}
|
||
|
||
/** 加载 AI 启用状态,用于控制批量设置中的入口显示 */
|
||
async function loadAiConfig() {
|
||
try {
|
||
const res: any = await getAiConfig()
|
||
aiEnabled.value = !!res.data.enabled
|
||
} catch (e) {
|
||
// 静默处理,AI 入口保持隐藏
|
||
aiEnabled.value = false
|
||
}
|
||
}
|
||
|
||
/** 请求 AI 建议:对当前表格展示的记录生成分类与标签建议 */
|
||
async function handleAiSuggest() {
|
||
if (transactions.value.length === 0) {
|
||
message.warning(t('transaction.noRecordsToSet'))
|
||
return
|
||
}
|
||
aiLoading.value = true
|
||
try {
|
||
const ids = transactions.value.map((tx) => tx.id)
|
||
const res: any = await suggestByAi(ids, aiScope.value)
|
||
aiSuggestions.value = res.data || []
|
||
if (aiSuggestions.value.length === 0) {
|
||
message.warning(t('ai.noSuggestion'))
|
||
return
|
||
}
|
||
// 默认全选,用户可取消不需要的建议
|
||
checkedAiRowKeys.value = aiSuggestions.value.map((s) => s.transactionId)
|
||
message.success(t('ai.aggregated', { groups: aiSuggestions.value.length, count: aiTotalCount.value }))
|
||
showBatchModal.value = false
|
||
showAiModal.value = true
|
||
} catch (e: any) {
|
||
message.error(e.message || t('ai.suggestFailed'))
|
||
} finally {
|
||
aiLoading.value = false
|
||
}
|
||
}
|
||
|
||
/** 编辑弹窗内 AI 推荐分类:基于表单当前内容(类型、金额、交易渠道、备注) */
|
||
async function handleAiSuggestOne() {
|
||
aiFormLoading.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.categoryId == null) {
|
||
message.warning(t('ai.noSuggestion'))
|
||
return
|
||
}
|
||
form.value.categoryId = suggestion.categoryId
|
||
message.success(t('ai.oneApplied'))
|
||
} catch (e: any) {
|
||
message.error(e.message || t('ai.suggestFailed'))
|
||
} finally {
|
||
aiFormLoading.value = false
|
||
}
|
||
}
|
||
|
||
/** 编辑弹窗内根据账单信息生成新分类(暂不保存,等待用户确认) */
|
||
async function handleAiNewCategory() {
|
||
if (!form.value.remark && !form.value.amount) {
|
||
message.warning(t('ai.newCategory.needInfo'))
|
||
return
|
||
}
|
||
aiNewLoading.value = true
|
||
try {
|
||
const res: any = await parseCategoryByAi(buildCategoryPrompt())
|
||
const data = res.data
|
||
if (!data || !data.name) {
|
||
message.warning(t('ai.category.failed'))
|
||
return
|
||
}
|
||
// 类型跟随当前记录,避免与所选收支类型不一致
|
||
pendingCategory.value = { name: data.name, type: form.value.type, icon: data.icon || '' }
|
||
} catch (e: any) {
|
||
message.error(e.message || t('ai.category.failed'))
|
||
} finally {
|
||
aiNewLoading.value = false
|
||
}
|
||
}
|
||
|
||
/** 拼接用于生成分类的账单描述 */
|
||
function buildCategoryPrompt() {
|
||
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(',')
|
||
}
|
||
|
||
/** 确认保存 AI 生成的新分类并选用 */
|
||
async function handleSaveNewCategory() {
|
||
const pending = pendingCategory.value
|
||
if (!pending) {
|
||
return
|
||
}
|
||
// 已存在同名同类型分类时直接选用,避免重复创建
|
||
const existing = categories.value.find((c) => c.label === pending.name && c.type === pending.type)
|
||
if (existing) {
|
||
form.value.categoryId = existing.value
|
||
message.info(t('ai.newCategory.exists'))
|
||
pendingCategory.value = null
|
||
return
|
||
}
|
||
aiNewSaving.value = true
|
||
try {
|
||
await createCategory({ name: pending.name, type: pending.type, icon: pending.icon, sortOrder: 0 })
|
||
await fetchCategories()
|
||
// 排序值相同,取同名同类型中的最后一个(最新创建)
|
||
const created = [...categories.value].reverse().find((c) => c.label === pending.name && c.type === pending.type)
|
||
if (created) {
|
||
form.value.categoryId = created.value
|
||
}
|
||
message.success(t('ai.newCategory.saved'))
|
||
pendingCategory.value = null
|
||
} catch (e: any) {
|
||
message.error(e.message || t('common.operationFailed'))
|
||
} finally {
|
||
aiNewSaving.value = false
|
||
}
|
||
}
|
||
|
||
/** 编辑弹窗内 AI 推荐已有标签:基于表单当前内容,追加到已选标签 */
|
||
async function handleAiTagSuggest() {
|
||
aiTagSuggestLoading.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.tagIds || suggestion.tagIds.length === 0) {
|
||
message.warning(t('ai.noSuggestion'))
|
||
return
|
||
}
|
||
// 合并去重,保留已选标签
|
||
form.value.tagIds = Array.from(new Set([...form.value.tagIds, ...suggestion.tagIds]))
|
||
message.success(t('ai.tagApplied'))
|
||
} catch (e: any) {
|
||
message.error(e.message || t('ai.suggestFailed'))
|
||
} finally {
|
||
aiTagSuggestLoading.value = false
|
||
}
|
||
}
|
||
|
||
/** 编辑弹窗内 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) {
|
||
message.warning(t('ai.newTag.needInfo'))
|
||
return
|
||
}
|
||
aiTagNewLoading.value = true
|
||
try {
|
||
const res: any = await parseTagByAi(buildCategoryPrompt())
|
||
const data = res.data
|
||
if (!data || !data.name) {
|
||
message.warning(t('ai.newTag.failed'))
|
||
return
|
||
}
|
||
pendingTag.value = { name: data.name, color: data.color || '#2080f0' }
|
||
} catch (e: any) {
|
||
message.error(e.message || t('ai.newTag.failed'))
|
||
} finally {
|
||
aiTagNewLoading.value = false
|
||
}
|
||
}
|
||
|
||
/** 确认保存 AI 生成的新标签并选用 */
|
||
async function handleSaveNewTag() {
|
||
const pending = pendingTag.value
|
||
if (!pending) {
|
||
return
|
||
}
|
||
// 已存在同名标签时直接选用,避免重复创建
|
||
const existing = allTags.value.find((tag) => tag.name === pending.name)
|
||
if (existing) {
|
||
addTagToForm(existing.id)
|
||
message.info(t('ai.newTag.exists'))
|
||
pendingTag.value = null
|
||
return
|
||
}
|
||
aiTagSaving.value = true
|
||
try {
|
||
await createTag({ name: pending.name, color: pending.color, sortOrder: 0 })
|
||
await fetchTags()
|
||
// 排序值相同,取同名中的最后一个(最新创建)
|
||
const created = [...allTags.value].reverse().find((tag) => tag.name === pending.name)
|
||
if (created) {
|
||
addTagToForm(created.id)
|
||
}
|
||
message.success(t('ai.newTag.saved'))
|
||
pendingTag.value = null
|
||
} catch (e: any) {
|
||
message.error(e.message || t('common.operationFailed'))
|
||
} finally {
|
||
aiTagSaving.value = false
|
||
}
|
||
}
|
||
|
||
/** 将标签追加到表单已选标签中(去重) */
|
||
function addTagToForm(tagId: number) {
|
||
if (!form.value.tagIds.includes(tagId)) {
|
||
form.value.tagIds = [...form.value.tagIds, tagId]
|
||
}
|
||
}
|
||
|
||
/** 一键应用全部 AI 建议:覆盖分类并追加标签 */
|
||
async function handleAiApply() {
|
||
const selected = new Set(checkedAiRowKeys.value)
|
||
const chosen = aiSuggestions.value.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 }))
|
||
showAiModal.value = false
|
||
await fetchData()
|
||
// 应用过程中可能新建了分类或标签,刷新以保持一致
|
||
await fetchCategories()
|
||
await fetchTags()
|
||
} catch (e: any) {
|
||
message.error(e.message || t('ai.applyFailed'))
|
||
} finally {
|
||
aiApplying.value = false
|
||
}
|
||
}
|
||
|
||
/** 打开 AI 记账弹窗 */
|
||
function handleAiRecordOpen() {
|
||
if (!aiEnabled.value) {
|
||
message.warning(t('ai.needConfig'))
|
||
return
|
||
}
|
||
abortAiRecordStream()
|
||
aiRecordText.value = ''
|
||
// 沿用上次的记账方式偏好(文字/图片),不强制重置
|
||
aiRecordFileList.value = []
|
||
aiRecordFiles.value = []
|
||
aiRecordHint.value = ''
|
||
aiRecordProgress.value = []
|
||
showAiRecordModal.value = true
|
||
}
|
||
|
||
/** 解析自然语言记账并预填新增表单 */
|
||
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
|
||
editingId.value = null
|
||
form.value = {
|
||
categoryId: r.categoryId ?? null,
|
||
type: r.type || 'EXPENSE',
|
||
amount: r.amount ?? null,
|
||
channel: r.channel || '',
|
||
remark: r.remark || '',
|
||
transactionTime: r.transactionTime ? new Date(r.transactionTime).getTime() : Date.now(),
|
||
includeInStats: r.includeInStats ?? true,
|
||
tagIds: r.tagIds || [],
|
||
}
|
||
showAiRecordModal.value = false
|
||
showModal.value = true
|
||
if (r.summary) {
|
||
message.info(r.summary)
|
||
}
|
||
} 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 (!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) {
|
||
message.warning(t('ai.needConfig'))
|
||
return
|
||
}
|
||
showAiQueryModal.value = true
|
||
}
|
||
|
||
/** 请求 AI 解析自然语言查询 */
|
||
async function handleAiQuery() {
|
||
if (!aiEnabled.value) {
|
||
message.warning(t('ai.needConfig'))
|
||
return
|
||
}
|
||
const text = aiQueryText.value.trim()
|
||
if (!text) {
|
||
message.warning(t('ai.query.emptyInput'))
|
||
return
|
||
}
|
||
aiQueryHistory.record()
|
||
aiQuerying.value = true
|
||
try {
|
||
const res: any = await queryByAi(text)
|
||
aiQueryResult.value = res.data
|
||
// 无任何可用筛选条件时提示,避免误清空当前筛选
|
||
if (aiQueryEmpty.value) {
|
||
message.warning(t('ai.query.noCondition'))
|
||
return
|
||
}
|
||
// 直接应用解析结果并搜索,无需二次确认
|
||
applyAiQuery()
|
||
showAiQueryModal.value = false
|
||
} catch (e: any) {
|
||
message.error(e.message || t('ai.query.failed'))
|
||
} finally {
|
||
aiQuerying.value = false
|
||
}
|
||
}
|
||
|
||
/** 将 AI 解析结果应用到筛选条件并查询 */
|
||
function applyAiQuery() {
|
||
const r = aiQueryResult.value
|
||
if (!r) return
|
||
filterType.value = r.types || []
|
||
const categoryIds = [...(r.categoryIds || [])]
|
||
if (r.uncategorized) categoryIds.push(0)
|
||
filterCategory.value = categoryIds
|
||
filterChannel.value = r.channels || []
|
||
filterDateRange.value = r.startTime && r.endTime
|
||
? [new Date(r.startTime).getTime(), new Date(r.endTime).getTime()]
|
||
: null
|
||
filterIncludeInStats.value = r.includeInStats == null ? null : (r.includeInStats ? 'true' : 'false')
|
||
filterMinAmount.value = r.minAmount ?? null
|
||
filterMaxAmount.value = r.maxAmount ?? null
|
||
filterRemark.value = r.remark || ''
|
||
filterTag.value = r.tagIds || []
|
||
handleSearch()
|
||
// 成功提示优先展示 AI 识别的查询意图
|
||
message.success(r.summary || t('ai.query.applied'))
|
||
}
|
||
|
||
/** 解析结果是否为空(无任何可用筛选条件) */
|
||
const aiQueryEmpty = computed(() => {
|
||
const r = aiQueryResult.value
|
||
if (!r) return true
|
||
return (r.types?.length ?? 0) === 0
|
||
&& (r.categoryIds?.length ?? 0) === 0
|
||
&& !r.uncategorized
|
||
&& (r.tagIds?.length ?? 0) === 0
|
||
&& (r.channels?.length ?? 0) === 0
|
||
&& !r.startTime
|
||
&& r.minAmount == null
|
||
&& r.maxAmount == null
|
||
&& !r.remark
|
||
&& r.includeInStats == null
|
||
})
|
||
|
||
/** AI 查询示例(从 i18n 拆分为数组) */
|
||
const queryExamples = computed(() => t('ai.query.examples').split('|'))
|
||
|
||
/** AI 查询示例下拉选项 */
|
||
const exampleOptions = computed(() => queryExamples.value.map((example) => ({ label: example, key: example })))
|
||
|
||
/** 选择示例后填入输入框 */
|
||
function handleExampleSelect(key: string) {
|
||
aiQueryText.value = key
|
||
}
|
||
|
||
/** 选中历史项:填入并立即发起查询 */
|
||
function handleAiQuerySelect(value: string) {
|
||
if (value === aiQueryHistory.clearValue) {
|
||
aiQueryHistory.clear()
|
||
return
|
||
}
|
||
aiQueryText.value = value
|
||
handleAiQuery()
|
||
}
|
||
|
||
/** 回车时发起查询(阻止 NAutoComplete 内部选中建议项) */
|
||
function handleAiQueryKeydown(e: KeyboardEvent) {
|
||
if (e.key === 'Enter') {
|
||
e.stopPropagation()
|
||
handleAiQuery()
|
||
}
|
||
}
|
||
|
||
/** 将时间戳格式化为 yyyy-MM-dd */
|
||
function formatDateLabel(ts: number) {
|
||
const d = new Date(ts)
|
||
const pad = (n: number) => String(n).padStart(2, '0')
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||
}
|
||
|
||
/** 已激活的筛选条件标签(可单独移除) */
|
||
const activeFilterTags = computed(() => {
|
||
const tags: { key: string; label: string; clear: () => void }[] = []
|
||
|
||
filterType.value.forEach((v) => {
|
||
tags.push({
|
||
key: `type:${v}`,
|
||
label: v === 'INCOME' ? t('common.types.income') : t('common.types.expense'),
|
||
clear: () => { filterType.value = filterType.value.filter((x) => x !== v) },
|
||
})
|
||
})
|
||
|
||
filterCategory.value.forEach((id) => {
|
||
const name = id === 0
|
||
? t('transaction.uncategorized')
|
||
: categories.value.find((c) => c.value === id)?.label || String(id)
|
||
tags.push({
|
||
key: `category:${id}`,
|
||
label: name,
|
||
clear: () => { filterCategory.value = filterCategory.value.filter((x) => x !== id) },
|
||
})
|
||
})
|
||
|
||
if (filterDateRange.value) {
|
||
tags.push({
|
||
key: 'date',
|
||
label: `${formatDateLabel(filterDateRange.value[0])} ~ ${formatDateLabel(filterDateRange.value[1])}`,
|
||
clear: () => { filterDateRange.value = null },
|
||
})
|
||
}
|
||
|
||
if (filterRemark.value.trim()) {
|
||
tags.push({
|
||
key: 'remark',
|
||
label: `${t('transaction.column.remark')}: ${filterRemark.value.trim()}`,
|
||
clear: () => { filterRemark.value = '' },
|
||
})
|
||
}
|
||
|
||
filterChannel.value.forEach((v) => {
|
||
tags.push({
|
||
key: `channel:`,
|
||
label: getChannelLabel(v),
|
||
clear: () => { filterChannel.value = filterChannel.value.filter((x) => x !== v) },
|
||
})
|
||
})
|
||
|
||
filterTag.value.forEach((id) => {
|
||
const name = id === 0
|
||
? t('transaction.untagged')
|
||
: (allTags.value.find((tag) => tag.id === id)?.name || String(id))
|
||
tags.push({
|
||
key: `tag:${id}`,
|
||
label: name,
|
||
clear: () => { filterTag.value = filterTag.value.filter((x) => x !== id) },
|
||
})
|
||
})
|
||
|
||
if (filterIncludeInStats.value !== null) {
|
||
tags.push({
|
||
key: 'includeInStats',
|
||
label: filterIncludeInStats.value === 'true' ? t('transaction.includeFilter.yes') : t('transaction.includeFilter.no'),
|
||
clear: () => { filterIncludeInStats.value = null },
|
||
})
|
||
}
|
||
|
||
if (filterMinAmount.value != null || filterMaxAmount.value != null) {
|
||
tags.push({
|
||
key: 'amount',
|
||
label: `${t('transaction.amountRange')}: ${filterMinAmount.value ?? 0} ~ ${filterMaxAmount.value ?? '∞'}`,
|
||
clear: () => { filterMinAmount.value = null; filterMaxAmount.value = null },
|
||
})
|
||
}
|
||
|
||
return tags
|
||
})
|
||
|
||
/** 移除单个筛选条件后自动重查 */
|
||
function applyTagClear(tag: { clear: () => void }) {
|
||
tag.clear()
|
||
handleSearch()
|
||
}
|
||
|
||
/** AI 建议行:合并代表交易记录信息用于展示 */
|
||
const aiRows = computed(() =>
|
||
aiSuggestions.value.map((s) => ({
|
||
...s,
|
||
transaction: transactions.value.find((tx) => tx.id === s.transactionId) || null,
|
||
})),
|
||
)
|
||
|
||
/** 已勾选 AI 建议覆盖的记录总数(聚合组按组内记录数累加) */
|
||
const aiTotalCount = computed(() => {
|
||
const selected = new Set(checkedAiRowKeys.value)
|
||
return aiSuggestions.value
|
||
.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>()
|
||
aiSuggestions.value.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>()
|
||
aiSuggestions.value.forEach((s) => {
|
||
if (selected.has(s.transactionId) && s.newTags) {
|
||
s.newTags.forEach((tag) => names.add(tag.name))
|
||
}
|
||
})
|
||
return Array.from(names)
|
||
})
|
||
|
||
/** AI 建议表格列定义(随语言切换与建议范围响应式更新) */
|
||
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 (aiScope.value !== 'TAG') {
|
||
columns.push({
|
||
title: t('ai.column.category'), key: 'category', minWidth: 150,
|
||
render: (row) => renderCategoryCell(row),
|
||
})
|
||
}
|
||
if (aiScope.value !== '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 建议的分类单元格,新建分类带「新建」标记 */
|
||
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 || '-'
|
||
}
|
||
|
||
/** 渲染 AI 建议的标签单元格,新建标签带「新建」标记并使用建议颜色 */
|
||
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 }) : '-'
|
||
}
|
||
|
||
/** 打开批量设置分类弹窗 */
|
||
function handleBatchOpen() {
|
||
if (transactions.value.length === 0) {
|
||
message.warning(t('transaction.noRecordsToSet'))
|
||
return
|
||
}
|
||
batchExpenseCategoryId.value = null
|
||
batchIncomeCategoryId.value = null
|
||
batchIncludeInStats.value = 'null'
|
||
batchAddTagIds.value = []
|
||
batchRemoveTagIds.value = []
|
||
showBatchModal.value = true
|
||
}
|
||
|
||
/** 对当前表格展示的记录按类型批量设置分类 */
|
||
async function handleBatchSubmit() {
|
||
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 = transactions.value.map((tx) => tx.id)
|
||
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 }))
|
||
showBatchModal.value = false
|
||
await fetchData()
|
||
} catch (e: any) {
|
||
message.error(e.message || t('common.operationFailed'))
|
||
} finally {
|
||
batching.value = false
|
||
}
|
||
}
|
||
|
||
/** 导出中状态,防止重复点击 */
|
||
const exporting = ref(false)
|
||
|
||
/** 导出交易记录:弹出二次确认,确认后执行导出 */
|
||
function handleExport() {
|
||
dialog.warning({
|
||
title: t('transaction.exportConfirmTitle'),
|
||
content: t('transaction.exportConfirm'),
|
||
positiveText: t('common.confirm'),
|
||
negativeText: t('common.cancel'),
|
||
onPositiveClick: () => doExport(),
|
||
})
|
||
}
|
||
|
||
/** 实际执行导出交易记录为 Excel 文件 */
|
||
async function doExport() {
|
||
exporting.value = true
|
||
try {
|
||
const res = await exportTransaction({
|
||
types: filterType.value.length > 0 ? filterType.value : undefined,
|
||
...buildCategoryFilter(),
|
||
channels: filterChannel.value.length > 0 ? filterChannel.value : undefined,
|
||
startTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[0]) : undefined,
|
||
endTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[1], true) : undefined,
|
||
includeInStats: filterIncludeInStats.value === null ? undefined : filterIncludeInStats.value === 'true',
|
||
minAmount: filterMinAmount.value ?? undefined,
|
||
maxAmount: filterMaxAmount.value ?? undefined,
|
||
remark: filterRemark.value.trim() || undefined,
|
||
sortField: sortField.value,
|
||
sortOrder: sortOrder.value,
|
||
...buildTagFilter(),
|
||
})
|
||
const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
||
const url = window.URL.createObjectURL(blob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = `${t('transaction.exportFileName')}_${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.xlsx`
|
||
document.body.appendChild(link)
|
||
link.click()
|
||
document.body.removeChild(link)
|
||
window.URL.revokeObjectURL(url)
|
||
message.success(t('transaction.exportSuccess'))
|
||
} catch (e: any) {
|
||
message.error(e.message || t('transaction.exportFailed'))
|
||
} finally {
|
||
exporting.value = false
|
||
}
|
||
}
|
||
|
||
// 组件挂载时加载分类和交易记录
|
||
onMounted(() => {
|
||
loadFilterPrefs()
|
||
remarkHistory.load()
|
||
aiQueryHistory.load()
|
||
loadAiConfig()
|
||
loadExcludedDisplayStyle()
|
||
fetchCategories()
|
||
fetchTags()
|
||
loadChannels()
|
||
fetchData()
|
||
// 监听剪贴板粘贴,支持在图片记账弹窗中直接粘贴图片
|
||
window.addEventListener('paste', handleAiRecordPaste)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('paste', handleAiRecordPaste)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div>
|
||
<!-- 顶部:标题和操作按钮 -->
|
||
<NSpace justify="space-between" align="center" style="margin-bottom: 16px;" :wrap="true">
|
||
<h2 style="margin: 0;">{{ t('transaction.title') }}</h2>
|
||
<NSpace align="center" :wrap="true">
|
||
<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 @click="handleBatchOpen">{{ t('transaction.batchSet') }}</NButton>
|
||
<NButton type="error" :disabled="checkedTransactionIds.length === 0" @click="handleBatchDelete">
|
||
{{ 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>
|
||
<NIcon :component="RefreshOutline" />
|
||
</template>
|
||
{{ t('common.refresh') }}
|
||
</NButton>
|
||
<NButton v-if="aiEnabled" @click="handleAiRecordOpen">
|
||
<template #icon>
|
||
<NIcon :component="SparklesOutline" />
|
||
</template>
|
||
{{ t('ai.record.button') }}
|
||
</NButton>
|
||
<NButton type="primary" @click="handleAdd">{{ t('transaction.addRecord') }}</NButton>
|
||
</NSpace>
|
||
</NSpace>
|
||
|
||
<!-- 查询与筛选 -->
|
||
<NCard :bordered="false" style="margin-bottom: 16px;">
|
||
<!-- 常用筛选条件 -->
|
||
<NSpace align="center" :wrap="true" :size="[12, 8]">
|
||
<NSelect
|
||
v-model:value="filterType"
|
||
:options="typeOptions"
|
||
multiple
|
||
clearable
|
||
:max-tag-count="2"
|
||
:placeholder="t('transaction.column.type')"
|
||
style="width: 160px;"
|
||
/>
|
||
<NSelect
|
||
v-model:value="filterCategory"
|
||
:options="categoryFilterOptions"
|
||
:render-label="renderCategoryLabel"
|
||
:render-tag="renderCategoryTag"
|
||
multiple
|
||
clearable
|
||
filterable
|
||
:max-tag-count="2"
|
||
:placeholder="t('transaction.column.category')"
|
||
style="width: 200px;"
|
||
/>
|
||
<NDatePicker
|
||
v-model:value="filterDateRange"
|
||
type="daterange"
|
||
clearable
|
||
style="width: 260px;"
|
||
/>
|
||
<NAutoComplete
|
||
:value="filterRemark"
|
||
:options="remarkHistory.options.value"
|
||
:get-show="remarkHistory.showMenu"
|
||
:render-label="remarkHistory.renderLabel"
|
||
:input-props="{ onKeydown: handleRemarkKeydown }"
|
||
clearable
|
||
:placeholder="t('transaction.column.remark')"
|
||
style="width: 180px;"
|
||
@update:value="remarkHistory.handleInput"
|
||
@select="handleRemarkSelect"
|
||
@blur="handleRemarkBlur"
|
||
/>
|
||
<NSelect
|
||
v-model:value="filterChannel"
|
||
:options="channelOptions"
|
||
multiple
|
||
clearable
|
||
:max-tag-count="2"
|
||
:placeholder="t('transaction.column.channel')"
|
||
style="width: 180px;"
|
||
/>
|
||
<NPopover v-model:show="showMoreFilter" trigger="click" placement="bottom-start">
|
||
<template #trigger>
|
||
<NButton>
|
||
{{ t('transaction.moreFilter') }}
|
||
<NTag v-if="moreFilterCount > 0" :bordered="false" size="small" type="primary" style="margin-left: 6px;">
|
||
{{ moreFilterCount }}
|
||
</NTag>
|
||
</NButton>
|
||
</template>
|
||
<NSpace vertical :size="12" style="width: 260px;">
|
||
<div>
|
||
<div class="filter-label">{{ t('transaction.column.tags') }}</div>
|
||
<NSelect
|
||
v-model:value="filterTag"
|
||
:options="tagFilterSelectOptions"
|
||
multiple
|
||
clearable
|
||
filterable
|
||
:max-tag-count="2"
|
||
:placeholder="t('common.all')"
|
||
style="width: 100%;"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<div class="filter-label">{{ t('transaction.column.includeInStats') }}</div>
|
||
<NSelect
|
||
v-model:value="filterIncludeInStats"
|
||
:options="includeInStatsOptions"
|
||
clearable
|
||
:placeholder="t('common.all')"
|
||
style="width: 100%;"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<div class="filter-label">{{ t('transaction.amountRange') }}</div>
|
||
<NSpace align="center" :size="8">
|
||
<NInputNumber
|
||
v-model:value="filterMinAmount"
|
||
:min="0"
|
||
:precision="2"
|
||
clearable
|
||
:placeholder="t('common.min')"
|
||
style="width: 110px;"
|
||
/>
|
||
<span style="color: #999;">~</span>
|
||
<NInputNumber
|
||
v-model:value="filterMaxAmount"
|
||
:min="0"
|
||
:precision="2"
|
||
clearable
|
||
:placeholder="t('common.max')"
|
||
style="width: 110px;"
|
||
/>
|
||
</NSpace>
|
||
</div>
|
||
<NSpace justify="end">
|
||
<NButton size="small" @click="clearMoreFilters">{{ t('transaction.clear') }}</NButton>
|
||
</NSpace>
|
||
</NSpace>
|
||
</NPopover>
|
||
<NButton type="primary" @click="handleSearch">{{ t('transaction.search') }}</NButton>
|
||
<NButton @click="handleReset">{{ t('transaction.reset') }}</NButton>
|
||
<NButton v-if="aiEnabled" @click="handleAiQueryOpen">
|
||
<template #icon>
|
||
<NIcon :component="SparklesOutline" />
|
||
</template>
|
||
{{ t('ai.query.entry') }}
|
||
</NButton>
|
||
</NSpace>
|
||
|
||
<!-- 已激活的筛选条件 -->
|
||
<div v-if="activeFilterTags.length > 0" class="active-filters">
|
||
<NSpace align="center" :wrap="true" :size="[8, 8]">
|
||
<span style="color: #999; font-size: 12px;">{{ t('transaction.activeFilters') }}</span>
|
||
<NTag
|
||
v-for="tag in activeFilterTags"
|
||
:key="tag.key"
|
||
size="small"
|
||
closable
|
||
@close="applyTagClear(tag)"
|
||
>
|
||
{{ tag.label }}
|
||
</NTag>
|
||
<NButton text size="tiny" type="primary" @click="handleReset">{{ t('transaction.clearAll') }}</NButton>
|
||
</NSpace>
|
||
</div>
|
||
</NCard>
|
||
|
||
<!-- 表格信息栏(右上角) -->
|
||
<NSpace justify="end" align="center" :size="12" style="margin-bottom: 8px;">
|
||
<span style="color: #999; font-size: 12px;">
|
||
{{ t('transaction.summary', { total, page, pages: totalPages, size: pageSize }) }}
|
||
</span>
|
||
<span style="font-size: 12px; color: #999;">
|
||
{{ t('transaction.pageExpense') }} <span style="color: #d03050;">{{ pageSummary.expense.toFixed(2) }}</span>
|
||
· {{ t('transaction.pageIncome') }} <span style="color: #18a058;">{{ pageSummary.income.toFixed(2) }}</span>
|
||
· {{ t('transaction.pageBalance') }} <span :style="{ color: pageSummary.balance < 0 ? '#d03050' : '#18a058' }">{{ pageSummary.balance.toFixed(2) }}</span>
|
||
<span style="margin-left: 8px;">
|
||
{{ t('transaction.pageExcluded', { expense: pageExcludedSummary.expense.toFixed(2), income: pageExcludedSummary.income.toFixed(2) }) }}
|
||
</span>
|
||
</span>
|
||
</NSpace>
|
||
|
||
<!-- 交易记录数据表格 -->
|
||
<NDataTable
|
||
:columns="columns"
|
||
:data="transactions"
|
||
:loading="loading"
|
||
:bordered="false"
|
||
:row-key="(row: TransactionItem) => row.id"
|
||
:row-class-name="(row: TransactionItem) => (!row.includeInStats && excludedDisplayStyle === 'strikethrough') ? 'excluded-row' : ''"
|
||
v-model:checked-row-keys="checkedTransactionIds"
|
||
/>
|
||
|
||
<!-- 分页控件 -->
|
||
<NSpace justify="center" style="margin-top: 16px;">
|
||
<NPagination
|
||
v-model:page="page"
|
||
v-model:page-size="pageSize"
|
||
:page-count="Math.ceil(total / pageSize)"
|
||
:page-sizes="pageSizes"
|
||
show-size-picker
|
||
show-quick-jumper
|
||
@update:page="handlePageChange"
|
||
@update:page-size="handlePageSizeChange"
|
||
/>
|
||
</NSpace>
|
||
|
||
<!-- 新增/编辑弹窗 -->
|
||
<NModal v-model:show="showModal" preset="card" :title="editingId ? t('transaction.editRecord') : t('transaction.addRecordTitle')" style="width: 500px;">
|
||
<NForm>
|
||
<NFormItem :label="t('transaction.column.type')">
|
||
<NSelect v-model:value="form.type" :options="typeOptions" />
|
||
</NFormItem>
|
||
<NFormItem :label="t('transaction.column.category')">
|
||
<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" 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 生成的新分类确认条:用户确认后才保存 -->
|
||
<div v-if="pendingCategory" 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.newCategory.hint') }}</span>
|
||
<span style="font-weight: 600;">{{ pendingCategory.icon }} {{ pendingCategory.name }}</span>
|
||
<NButton size="tiny" type="primary" :loading="aiNewSaving" @click="handleSaveNewCategory">{{ t('ai.newCategory.save') }}</NButton>
|
||
<NButton size="tiny" @click="pendingCategory = null">{{ t('common.cancel') }}</NButton>
|
||
</div>
|
||
<NFormItem :label="t('transaction.column.amount')">
|
||
<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')">
|
||
<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>
|
||
<NFormItem :label="t('transaction.column.remark')">
|
||
<NInput v-model:value="form.remark" type="textarea" :placeholder="t('transaction.remarkPlaceholder')" />
|
||
</NFormItem>
|
||
<NFormItem :label="t('transaction.column.tags')">
|
||
<div style="display: flex; align-items: center; gap: 8px; width: 100%;">
|
||
<NSelect
|
||
v-model:value="form.tagIds"
|
||
:options="tagFilterOptions"
|
||
multiple
|
||
clearable
|
||
filterable
|
||
:placeholder="t('transaction.tagPlaceholder')"
|
||
style="flex: 1;"
|
||
/>
|
||
<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 生成的新标签确认条:用户确认后才保存 -->
|
||
<div v-if="pendingTag" 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.newTag.hint') }}</span>
|
||
<NTag size="small" :color="{ color: pendingTag.color, textColor: '#fff' }">{{ pendingTag.name }}</NTag>
|
||
<NButton size="tiny" type="primary" :loading="aiTagSaving" @click="handleSaveNewTag">{{ t('ai.newTag.save') }}</NButton>
|
||
<NButton size="tiny" @click="pendingTag = null">{{ t('common.cancel') }}</NButton>
|
||
</div>
|
||
<NFormItem :label="t('transaction.includeInStats')">
|
||
<NSwitch v-model:value="form.includeInStats" />
|
||
<span style="color: #999; margin-left: 8px; font-size: 12px;">{{ t('transaction.includeInStatsHint') }}</span>
|
||
</NFormItem>
|
||
<NButton type="primary" block :loading="submitting" @click="handleSubmit">{{ t('common.confirm') }}</NButton>
|
||
</NForm>
|
||
</NModal>
|
||
|
||
<!-- 账单导入弹窗 -->
|
||
<BillImportModal v-model:show="showImportModal" @imported="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>
|
||
<NFormItem :label="t('transaction.batchScope')">
|
||
<span style="color: #999; font-size: 12px;">
|
||
{{ t('transaction.batchScopeHint', { count: transactions.length }) }}
|
||
</span>
|
||
</NFormItem>
|
||
<NFormItem v-if="aiEnabled" :label="t('ai.suggestButton')">
|
||
<div style="display: flex; gap: 8px; width: 100%;">
|
||
<NSelect v-model:value="aiScope" :options="aiScopeOptions" style="flex: 1;" />
|
||
<NButton :loading="aiLoading" @click="handleAiSuggest">{{ t('ai.suggestButton') }}</NButton>
|
||
</div>
|
||
</NFormItem>
|
||
<NFormItem v-if="hasExpenseRecords" :label="t('transaction.expenseCategory')">
|
||
<NSelect
|
||
v-model:value="batchExpenseCategoryId"
|
||
:options="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="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="tagFilterOptions" multiple clearable filterable :placeholder="t('transaction.notAddTag')" />
|
||
</NFormItem>
|
||
<NFormItem :label="t('transaction.removeTag')">
|
||
<NSelect v-model:value="batchRemoveTagIds" :options="tagFilterOptions" multiple clearable filterable :placeholder="t('transaction.notRemoveTag')" />
|
||
</NFormItem>
|
||
<NButton type="primary" block :loading="batching" @click="handleBatchSubmit">{{ t('common.confirm') }}</NButton>
|
||
</NForm>
|
||
</NModal>
|
||
|
||
<!-- AI 建议弹窗(一键应用分类与标签建议) -->
|
||
<NModal v-if="aiEnabled" v-model:show="showAiModal" 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"
|
||
:row-key="(row) => row.transactionId"
|
||
size="small"
|
||
:bordered="false"
|
||
/>
|
||
<NSpace justify="end">
|
||
<NButton @click="showAiModal = false">{{ t('common.cancel') }}</NButton>
|
||
<NButton type="primary" :loading="aiApplying" :disabled="checkedAiRowKeys.length === 0" @click="handleAiApply">
|
||
{{ t('ai.applyAll', { count: aiTotalCount }) }}
|
||
</NButton>
|
||
</NSpace>
|
||
</NSpace>
|
||
</NModal>
|
||
|
||
<!-- 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">
|
||
{{ 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="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>
|
||
|
||
<!-- AI 智能查询弹窗 -->
|
||
<NModal v-if="aiEnabled" v-model:show="showAiQueryModal" preset="card" :title="t('ai.query.title')" style="width: 520px; max-width: 92vw;">
|
||
<NSpace vertical :size="12">
|
||
<NAlert type="info" :show-icon="true">{{ t('ai.query.hint') }}</NAlert>
|
||
<NAutoComplete
|
||
:value="aiQueryText"
|
||
:options="aiQueryHistory.options.value"
|
||
:get-show="aiQueryHistory.showMenu"
|
||
:render-label="aiQueryHistory.renderLabel"
|
||
:input-props="{ onKeydown: handleAiQueryKeydown }"
|
||
clearable
|
||
:placeholder="t('ai.query.placeholder')"
|
||
style="width: 100%;"
|
||
@update:value="aiQueryHistory.handleInput"
|
||
@select="handleAiQuerySelect"
|
||
>
|
||
<template #prefix>
|
||
<NIcon :component="SparklesOutline" />
|
||
</template>
|
||
</NAutoComplete>
|
||
<NSpace justify="space-between" align="center">
|
||
<NDropdown trigger="click" :options="exampleOptions" @select="handleExampleSelect">
|
||
<NButton text type="primary" size="small">
|
||
<template #icon>
|
||
<NIcon :component="BulbOutline" />
|
||
</template>
|
||
{{ t('transaction.exampleLabel') }}
|
||
</NButton>
|
||
</NDropdown>
|
||
<NSpace>
|
||
<NButton @click="showAiQueryModal = false">{{ t('common.cancel') }}</NButton>
|
||
<NButton type="primary" :loading="aiQuerying" @click="handleAiQuery">{{ t('ai.query.button') }}</NButton>
|
||
</NSpace>
|
||
</NSpace>
|
||
</NSpace>
|
||
</NModal>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.remark-cell {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
.remark-text {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.filter-label {
|
||
margin-bottom: 4px;
|
||
color: #999;
|
||
font-size: 12px;
|
||
}
|
||
.active-filters {
|
||
margin-top: 12px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||
}
|
||
/* 不计入统计的记录整行加删除线 */
|
||
:deep(.excluded-row .n-data-table-td) {
|
||
text-decoration: line-through;
|
||
}
|
||
/* 类型、金额列保持现状,不划删除线 */
|
||
:deep(.excluded-row .n-data-table-td[data-col-key="type"]),
|
||
:deep(.excluded-row .n-data-table-td[data-col-key="amount"]) {
|
||
text-decoration: none;
|
||
}
|
||
/* 备注单元格为块级结构,需单独划线以保持整行效果 */
|
||
: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>
|