feat: AI 图片记账、自定义交易渠道与淘宝/京东账单导入
- AI 图片记账:多图上传由视觉模型识别,SSE 实时推送进度,提取 rawData,按「类型+金额+时间」检测重复,支持缩略图与来源图片查看 - 自定义交易渠道:新增 t_channel 与渠道管理页,支持新建/改名/删除,并接入统计、导出与 AI 查询/记账/建议提示词 - AI 建议:单条建议新增交易渠道推荐(推荐已有渠道 / AI 新建渠道) - 账单导入:新增淘宝(xlsx)、京东(CSV)解析器 - 交易:新增批量创建接口,导入时按去重键兜底跳过重复 - 运行目录统一经 AppHome 解析,图片与数据文件落盘规范化 - 前端:AI 记账方式偏好记忆、AI 按钮与输入框对齐、来源图片弹窗支持加载原图
This commit is contained in:
@@ -1,23 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
// 收支记录管理组件:展示交易记录列表(分页),支持新增、编辑、删除、多条件筛选
|
||||
import { ref, onMounted, computed, h, watch } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, computed, h, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDivider, NDropdown } from 'naive-ui'
|
||||
import { EyeOutline, EyeOffOutline, SparklesOutline, BulbOutline, RefreshOutline } from '@vicons/ionicons5'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags, getChannels, batchDeleteTransactions } from '../api/transaction'
|
||||
import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi } from '../api/ai'
|
||||
import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDivider, NDropdown, NUpload, NUploadDragger, NTabs, NTabPane, NSpin } from 'naive-ui'
|
||||
import { EyeOutline, EyeOffOutline, SparklesOutline, BulbOutline, RefreshOutline, CloudUploadOutline, CheckmarkCircleOutline } from '@vicons/ionicons5'
|
||||
import type { DataTableColumns, UploadFileInfo } from 'naive-ui'
|
||||
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags, getChannels, batchDeleteTransactions, batchCreateTransactions } from '../api/transaction'
|
||||
import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi, parseChannelByAi, getAiImageBlob } from '../api/ai'
|
||||
import { createChannel } from '../api/channel'
|
||||
import type { AiSuggestion, AiQueryResult, AiRecordResult, AiRecordImageResult } from '../api/ai'
|
||||
import { getExcludedDisplayStyle } from '../api/setting'
|
||||
import { getCategoryList, createCategory } from '../api/category'
|
||||
import { getTagList, createTag } from '../api/tag'
|
||||
import { getChannelLabel } from '../utils/channel'
|
||||
import { renderCategoryLabel } from '../utils/render'
|
||||
import { useKeywordHistory } from '../composables/useKeywordHistory'
|
||||
import { openSse } from '../utils/sse'
|
||||
import BillImportModal from './BillImportModal.vue'
|
||||
|
||||
const message = useMessage() // 消息提示
|
||||
const dialog = useDialog() // 对话框
|
||||
const router = useRouter() // 路由
|
||||
const { t } = useI18n() // 国际化
|
||||
|
||||
/** 交易记录数据结构 */
|
||||
@@ -35,6 +39,8 @@ interface TransactionItem {
|
||||
includeInStats: boolean
|
||||
rawData: string | null
|
||||
tags: TagOption[]
|
||||
/** 来源图片ID列表(图片记账生成时存在) */
|
||||
imageIds: number[]
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -53,6 +59,27 @@ interface TagOption {
|
||||
color: string
|
||||
}
|
||||
|
||||
/** 图片记账结果编辑行 */
|
||||
interface AiRecordEditRow {
|
||||
key: number
|
||||
type: string
|
||||
amount: number | null
|
||||
categoryId: number | null
|
||||
channel: string
|
||||
remark: string
|
||||
transactionTime: number
|
||||
includeInStats: boolean
|
||||
tagIds: number[]
|
||||
/** 模型提取的原始字段 JSON 文本,导入时写入交易记录的原始数据 */
|
||||
rawData: string | null
|
||||
/** 来源图片序号(从 1 开始) */
|
||||
imageIndex: number | null
|
||||
/** 来源图片ID,导入时用于关联图片 */
|
||||
imageId: number | null
|
||||
/** 是否疑似重复(默认不勾选,可手动勾选强制导入) */
|
||||
duplicate: boolean
|
||||
}
|
||||
|
||||
// 列表数据和分页状态
|
||||
const transactions = ref<TransactionItem[]>([]) // 交易记录列表
|
||||
const total = ref(0) // 总记录数
|
||||
@@ -137,6 +164,10 @@ const aiNewSaving = ref(false) // 新分类保存状态
|
||||
// AI 生成但尚未保存的新分类,需用户确认后再落库
|
||||
const pendingCategory = ref<{ name: string; type: string; icon: string } | null>(null)
|
||||
const aiTagSuggestLoading = ref(false) // 编辑弹窗内「推荐已有标签」请求状态
|
||||
const aiChannelLoading = ref(false) // 编辑弹窗内「推荐交易渠道」请求状态
|
||||
const aiChannelNewLoading = ref(false) // 编辑弹窗内「生成新渠道」请求状态
|
||||
const aiChannelNewSaving = ref(false) // 编辑弹窗内「保存新渠道」请求状态
|
||||
const pendingChannel = ref<{ name: string } | null>(null) // AI 生成的新渠道(待用户确认保存)
|
||||
const aiTagNewLoading = ref(false) // 编辑弹窗内「生成新标签」请求状态
|
||||
const aiTagSaving = ref(false) // 新标签保存状态
|
||||
// AI 生成但尚未保存的新标签,需用户确认后再落库
|
||||
@@ -159,6 +190,54 @@ const aiQueryHistory = useKeywordHistory({
|
||||
const showAiRecordModal = ref(false) // AI 记账弹窗显示状态
|
||||
const aiRecordText = ref('') // AI 记账输入
|
||||
const aiRecording = ref(false) // AI 记账解析状态
|
||||
/** AI 记账方式偏好存储键(参考邮箱页 Tab 记忆) */
|
||||
const AI_RECORD_TAB_KEY = 'finance.transaction.aiRecordTab'
|
||||
|
||||
/** 读取上次的 AI 记账方式偏好,非法值回退文字记账 */
|
||||
function loadAiRecordTab(): 'text' | 'image' {
|
||||
try {
|
||||
const saved = localStorage.getItem(AI_RECORD_TAB_KEY)
|
||||
return saved === 'image' ? 'image' : 'text'
|
||||
} catch (e) {
|
||||
return 'text'
|
||||
}
|
||||
}
|
||||
|
||||
const aiRecordTab = ref<'text' | 'image'>(loadAiRecordTab()) // AI 记账方式:文字/图片(记忆用户偏好)
|
||||
|
||||
// 记住用户选择的 AI 记账方式
|
||||
watch(aiRecordTab, (value) => {
|
||||
try {
|
||||
localStorage.setItem(AI_RECORD_TAB_KEY, value)
|
||||
} catch (e) {
|
||||
// 忽略存储异常
|
||||
}
|
||||
})
|
||||
/** 图片记账单次最多上传的图片数量 */
|
||||
const MAX_AI_RECORD_IMAGES = 5
|
||||
const aiRecordFileList = ref<UploadFileInfo[]>([]) // 图片记账上传文件列表
|
||||
const aiRecordFiles = ref<File[]>([]) // 待识别的图片文件(最多 MAX_AI_RECORD_IMAGES 张)
|
||||
const aiRecordHint = ref('') // 图片识别补充说明
|
||||
const aiRecordingImage = ref(false) // 图片识别状态
|
||||
const showAiRecordResultModal = ref(false) // 图片记账结果弹窗显示状态
|
||||
const aiRecordSummary = ref('') // 图片识别摘要
|
||||
const aiRecordRows = ref<AiRecordEditRow[]>([]) // 图片识别结果行(可编辑)
|
||||
const checkedAiRecordKeys = ref<number[]>([]) // 图片识别结果勾选的行
|
||||
const aiRecordImporting = ref(false) // 图片记账导入状态
|
||||
const aiRecordProgress = ref<{ time: string; text: string }[]>([]) // 图片识别进度日志(含接收时间)
|
||||
let aiRecordStreamController: AbortController | null = null // 图片识别 SSE 连接控制器
|
||||
const showImageViewModal = ref(false) // 来源图片查看弹窗显示状态
|
||||
|
||||
/** 来源图片查看项:默认加载缩略图,用户可按需切换为原图 */
|
||||
interface ImageViewItem {
|
||||
id: number // 图片ID
|
||||
url: string // 当前展示的 objectURL
|
||||
isOriginal: boolean // 是否已加载原图
|
||||
loading: boolean // 原图加载状态
|
||||
}
|
||||
|
||||
const imageViewItems = ref<ImageViewItem[]>([]) // 来源图片列表
|
||||
const imageViewLoading = ref(false) // 来源图片加载状态
|
||||
const showRawModal = ref(false) // 原始账单数据弹窗显示状态
|
||||
const rawDataText = ref('') // 格式化后的原始账单数据文本
|
||||
const batchExpenseCategoryId = ref<number | null>(null) // 批量设置的支出分类
|
||||
@@ -521,6 +600,16 @@ function createColumns(): DataTableColumns<TransactionItem> {
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('transaction.sourceImages'), key: 'imageIds', width: 110, align: 'center',
|
||||
render(row) {
|
||||
if (!row.imageIds || row.imageIds.length === 0) {
|
||||
return h('span', { style: { color: '#ccc' } }, '-')
|
||||
}
|
||||
return h(NButton, { size: 'small', quaternary: true, type: 'primary', onClick: () => openImageView(row.imageIds) },
|
||||
{ default: () => t('transaction.viewImages', { count: row.imageIds.length }) })
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('transaction.column.actions'), key: 'actions', width: 240,
|
||||
render(row) {
|
||||
@@ -538,16 +627,21 @@ function createColumns(): DataTableColumns<TransactionItem> {
|
||||
|
||||
const columns = computed(() => createColumns()) // 表格列配置(随语言切换响应式更新)
|
||||
|
||||
/** 从后端加载交易渠道列表,供筛选与编辑表单使用 */
|
||||
/** 从后端加载交易渠道列表(内置 + 自定义),供筛选与编辑表单使用 */
|
||||
async function loadChannels() {
|
||||
try {
|
||||
const res: any = await getChannels()
|
||||
channelCodes.value = res.data || []
|
||||
channelCodes.value = (res.data || []).map((item: any) => item.code)
|
||||
} catch (e) {
|
||||
// 静默处理,交易渠道下拉保持为空
|
||||
}
|
||||
}
|
||||
|
||||
/** 跳转到交易渠道管理页 */
|
||||
function goChannelManage() {
|
||||
router.push('/channels')
|
||||
}
|
||||
|
||||
/** 加载「不计入统计」记录展示样式偏好,失败时保持默认删除线 */
|
||||
async function loadExcludedDisplayStyle() {
|
||||
try {
|
||||
@@ -631,12 +725,75 @@ function handleViewRaw(row: TransactionItem) {
|
||||
showRawModal.value = true
|
||||
}
|
||||
|
||||
/** 查看图片记账结果行的原始数据(格式化为缩进 JSON) */
|
||||
function handleViewAiRecordRaw(row: AiRecordEditRow) {
|
||||
if (!row.rawData) return
|
||||
try {
|
||||
rawDataText.value = JSON.stringify(JSON.parse(row.rawData), null, 2)
|
||||
} catch (e) {
|
||||
rawDataText.value = row.rawData
|
||||
}
|
||||
showRawModal.value = true
|
||||
}
|
||||
|
||||
/** 从接口响应中提取图片 Blob(blob 响应经拦截器后返回完整 axios response,需取其 data) */
|
||||
function extractBlob(res: any): Blob {
|
||||
return res?.data instanceof Blob ? res.data : (res instanceof Blob ? res : new Blob([res]))
|
||||
}
|
||||
|
||||
/** 查看交易记录的来源图片(默认加载缩略图,避免大图拖慢打开速度) */
|
||||
async function openImageView(imageIds: number[]) {
|
||||
if (!imageIds || imageIds.length === 0) return
|
||||
closeImageView()
|
||||
showImageViewModal.value = true
|
||||
imageViewLoading.value = true
|
||||
try {
|
||||
const items: ImageViewItem[] = []
|
||||
for (const id of imageIds) {
|
||||
const res: any = await getAiImageBlob(id, true)
|
||||
items.push({ id, url: URL.createObjectURL(extractBlob(res)), isOriginal: false, loading: false })
|
||||
}
|
||||
imageViewItems.value = items
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('common.loadFailed'))
|
||||
} finally {
|
||||
imageViewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 将指定来源图片切换为原图(替换并释放旧缩略图 objectURL) */
|
||||
async function loadOriginalImage(item: ImageViewItem) {
|
||||
if (item.isOriginal || item.loading) return
|
||||
item.loading = true
|
||||
try {
|
||||
const res: any = await getAiImageBlob(item.id)
|
||||
const url = URL.createObjectURL(extractBlob(res))
|
||||
URL.revokeObjectURL(item.url)
|
||||
item.url = url
|
||||
item.isOriginal = true
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('common.loadFailed'))
|
||||
} finally {
|
||||
item.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭来源图片弹窗并释放 objectURL */
|
||||
function closeImageView() {
|
||||
for (const item of imageViewItems.value) {
|
||||
URL.revokeObjectURL(item.url)
|
||||
}
|
||||
imageViewItems.value = []
|
||||
showImageViewModal.value = false
|
||||
}
|
||||
|
||||
/** 打开新增记录弹窗,重置表单 */
|
||||
function handleAdd() {
|
||||
editingId.value = null
|
||||
form.value = { categoryId: null, type: 'EXPENSE', amount: null, channel: '', remark: '', transactionTime: Date.now(), includeInStats: true, tagIds: [] }
|
||||
pendingCategory.value = null
|
||||
pendingTag.value = null
|
||||
pendingChannel.value = null
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
@@ -655,6 +812,7 @@ function handleEdit(row: TransactionItem) {
|
||||
}
|
||||
pendingCategory.value = null
|
||||
pendingTag.value = null
|
||||
pendingChannel.value = null
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
@@ -939,6 +1097,94 @@ async function handleAiTagSuggest() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 编辑弹窗内 AI 推荐交易渠道:基于表单当前内容,仅填充渠道,不覆盖分类与标签 */
|
||||
async function handleAiChannelSuggest() {
|
||||
aiChannelLoading.value = true
|
||||
try {
|
||||
const res: any = await suggestOneByAi({
|
||||
type: form.value.type,
|
||||
amount: form.value.amount,
|
||||
channel: form.value.channel || undefined,
|
||||
remark: form.value.remark || undefined,
|
||||
})
|
||||
const suggestion = res.data
|
||||
if (!suggestion || !suggestion.channel) {
|
||||
message.warning(t('ai.noChannelSuggestion'))
|
||||
return
|
||||
}
|
||||
form.value.channel = suggestion.channel
|
||||
message.success(t('ai.channelApplied'))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('ai.suggestFailed'))
|
||||
} finally {
|
||||
aiChannelLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 拼接用于生成渠道的账单描述 */
|
||||
function buildChannelPrompt() {
|
||||
const typeLabel = form.value.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')
|
||||
const parts = [`这是一笔${typeLabel}记录`]
|
||||
if (form.value.remark) parts.push(`备注:${form.value.remark}`)
|
||||
if (form.value.amount) parts.push(`金额:${form.value.amount}`)
|
||||
if (form.value.channel) parts.push(`当前渠道:${getChannelLabel(form.value.channel)}`)
|
||||
parts.push('请生成一个适合这类交易的记账渠道名称')
|
||||
return parts.join(',')
|
||||
}
|
||||
|
||||
/** 编辑弹窗内根据账单信息生成新渠道(暂不保存,等待用户确认) */
|
||||
async function handleAiNewChannel() {
|
||||
if (!form.value.remark && !form.value.amount) {
|
||||
message.warning(t('ai.newChannel.needInfo'))
|
||||
return
|
||||
}
|
||||
aiChannelNewLoading.value = true
|
||||
try {
|
||||
const res: any = await parseChannelByAi(buildChannelPrompt())
|
||||
const data = res.data
|
||||
if (!data || !data.name) {
|
||||
message.warning(t('ai.newChannel.failed'))
|
||||
return
|
||||
}
|
||||
pendingChannel.value = { name: data.name }
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('ai.newChannel.failed'))
|
||||
} finally {
|
||||
aiChannelNewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 确认保存 AI 生成的新渠道并选用 */
|
||||
async function handleSaveNewChannel() {
|
||||
const pending = pendingChannel.value
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
// 已存在同名渠道时直接选用,避免重复创建
|
||||
const existing = channelOptions.value.find((option) => option.label === pending.name)
|
||||
if (existing) {
|
||||
form.value.channel = existing.value
|
||||
message.info(t('ai.newChannel.exists'))
|
||||
pendingChannel.value = null
|
||||
return
|
||||
}
|
||||
aiChannelNewSaving.value = true
|
||||
try {
|
||||
const res: any = await createChannel(pending.name)
|
||||
await loadChannels()
|
||||
const created = res?.data
|
||||
if (created?.code) {
|
||||
form.value.channel = created.code
|
||||
}
|
||||
message.success(t('ai.newChannel.saved'))
|
||||
pendingChannel.value = null
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('common.operationFailed'))
|
||||
} finally {
|
||||
aiChannelNewSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 编辑弹窗内根据账单信息生成新标签(暂不保存,等待用户确认) */
|
||||
async function handleAiNewTag() {
|
||||
if (!form.value.remark && !form.value.amount) {
|
||||
@@ -1062,7 +1308,13 @@ function handleAiRecordOpen() {
|
||||
message.warning(t('ai.needConfig'))
|
||||
return
|
||||
}
|
||||
abortAiRecordStream()
|
||||
aiRecordText.value = ''
|
||||
// 沿用上次的记账方式偏好(文字/图片),不强制重置
|
||||
aiRecordFileList.value = []
|
||||
aiRecordFiles.value = []
|
||||
aiRecordHint.value = ''
|
||||
aiRecordProgress.value = []
|
||||
showAiRecordModal.value = true
|
||||
}
|
||||
|
||||
@@ -1100,6 +1352,281 @@ async function handleAiRecordParse() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 图片记账:设置待识别的图片文件列表(最多 MAX_AI_RECORD_IMAGES 张) */
|
||||
function setAiRecordFiles(files: File[]) {
|
||||
const limited = files.slice(0, MAX_AI_RECORD_IMAGES)
|
||||
aiRecordFiles.value = limited
|
||||
aiRecordFileList.value = limited.map((file, index) => ({
|
||||
id: `${Date.now()}-${index}`,
|
||||
name: file.name || `clipboard-${index + 1}.png`,
|
||||
status: 'finished',
|
||||
file,
|
||||
} as UploadFileInfo))
|
||||
}
|
||||
|
||||
/** 图片记账:处理上传文件变化,最多保留 MAX_AI_RECORD_IMAGES 张 */
|
||||
function handleAiRecordFileChange(options: { fileList: UploadFileInfo[] }) {
|
||||
const files = options.fileList
|
||||
.map((item) => item.file)
|
||||
.filter((file): file is File => !!file)
|
||||
setAiRecordFiles(files)
|
||||
}
|
||||
|
||||
/** 图片记账:追加一张图片(用于多次粘贴累加) */
|
||||
function appendAiRecordFile(file: File) {
|
||||
if (aiRecordFiles.value.length >= MAX_AI_RECORD_IMAGES) {
|
||||
message.warning(t('ai.record.maxImages', { count: MAX_AI_RECORD_IMAGES }))
|
||||
return
|
||||
}
|
||||
setAiRecordFiles([...aiRecordFiles.value, file])
|
||||
}
|
||||
|
||||
/** 图片记账:监听粘贴事件,从剪贴板读取图片(Ctrl+V / ⌘V),支持多次粘贴累加 */
|
||||
function handleAiRecordPaste(e: ClipboardEvent) {
|
||||
// 仅在图片记账弹窗打开时接收粘贴
|
||||
if (!showAiRecordModal.value) return
|
||||
const dt = e.clipboardData
|
||||
if (!dt) return
|
||||
for (let i = 0; i < dt.items.length; i++) {
|
||||
const item = dt.items[i]
|
||||
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile()
|
||||
if (file) {
|
||||
e.preventDefault()
|
||||
appendAiRecordFile(file)
|
||||
aiRecordTab.value = 'image'
|
||||
message.success(t('ai.record.pasteSuccess'))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 图片记账:应用识别结果并打开结果预览 */
|
||||
function applyAiRecordImageResult(result: AiRecordImageResult) {
|
||||
const records = result.records || []
|
||||
if (records.length === 0) {
|
||||
message.warning(t('ai.record.noRecord'))
|
||||
return
|
||||
}
|
||||
aiRecordSummary.value = result.summary || ''
|
||||
aiRecordRows.value = records.map((r, index) => ({
|
||||
key: index,
|
||||
type: r.type || 'EXPENSE',
|
||||
amount: r.amount ?? null,
|
||||
categoryId: r.categoryId ?? null,
|
||||
channel: r.channel || '',
|
||||
remark: r.remark || '',
|
||||
transactionTime: r.transactionTime ? new Date(r.transactionTime).getTime() : Date.now(),
|
||||
includeInStats: r.includeInStats ?? true,
|
||||
tagIds: r.tagIds || [],
|
||||
rawData: r.rawData ?? null,
|
||||
imageIndex: r.imageIndex ?? null,
|
||||
imageId: r.imageId ?? null,
|
||||
duplicate: r.duplicate ?? false,
|
||||
}))
|
||||
// 疑似重复的记录默认不勾选,用户可手动勾选强制导入
|
||||
checkedAiRecordKeys.value = aiRecordRows.value.filter((row) => !row.duplicate).map((row) => row.key)
|
||||
showAiRecordModal.value = false
|
||||
showAiRecordResultModal.value = true
|
||||
}
|
||||
|
||||
/** 图片记账:中断进行中的识别连接 */
|
||||
function abortAiRecordStream() {
|
||||
if (aiRecordStreamController) {
|
||||
aiRecordStreamController.abort()
|
||||
aiRecordStreamController = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 将时间格式化为 HH:mm:ss,用于进度日志前缀 */
|
||||
function formatProgressTime(date: Date): string {
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
/** 图片记账:通过 SSE 调用视觉模型识别,实时展示进度 */
|
||||
function handleAiRecordParseImage() {
|
||||
if (aiRecordFiles.value.length === 0) {
|
||||
message.warning(t('ai.record.emptyInput'))
|
||||
return
|
||||
}
|
||||
abortAiRecordStream()
|
||||
aiRecordProgress.value = []
|
||||
aiRecordingImage.value = true
|
||||
|
||||
const formData = new FormData()
|
||||
for (const file of aiRecordFiles.value) {
|
||||
formData.append('files', file)
|
||||
}
|
||||
const hint = aiRecordHint.value.trim()
|
||||
if (hint) formData.append('hint', hint)
|
||||
|
||||
aiRecordStreamController = openSse('/api/ai/record/image/stream', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
onEvent: (event, data) => {
|
||||
if (event === 'progress') {
|
||||
aiRecordProgress.value.push({ time: formatProgressTime(new Date()), text: data })
|
||||
} else if (event === 'done') {
|
||||
try {
|
||||
applyAiRecordImageResult(JSON.parse(data) as AiRecordImageResult)
|
||||
} catch (e) {
|
||||
message.error(t('ai.record.imageFailed'))
|
||||
}
|
||||
} else if (event === 'failed') {
|
||||
let reason = ''
|
||||
try {
|
||||
reason = JSON.parse(data)?.message || ''
|
||||
} catch (e) {
|
||||
reason = data
|
||||
}
|
||||
aiRecordProgress.value.push({
|
||||
time: formatProgressTime(new Date()),
|
||||
text: reason ? `${t('ai.record.imageFailed')}:${reason}` : t('ai.record.imageFailed'),
|
||||
})
|
||||
message.error(reason || t('ai.record.imageFailed'))
|
||||
}
|
||||
},
|
||||
onError: (e: any) => {
|
||||
message.error(e?.message || t('ai.record.imageFailed'))
|
||||
},
|
||||
onClose: () => {
|
||||
aiRecordingImage.value = false
|
||||
aiRecordStreamController = null
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 关闭 AI 记账弹窗,中断进行中的识别 */
|
||||
function handleAiRecordCancel() {
|
||||
abortAiRecordStream()
|
||||
aiRecordingImage.value = false
|
||||
showAiRecordModal.value = false
|
||||
}
|
||||
|
||||
/** 图片记账:导入勾选的结果行 */
|
||||
async function handleAiRecordImport() {
|
||||
const selected = aiRecordRows.value.filter((row) => checkedAiRecordKeys.value.includes(row.key))
|
||||
if (selected.length === 0) {
|
||||
message.warning(t('ai.record.selectAtLeastOne'))
|
||||
return
|
||||
}
|
||||
if (selected.some((row) => !row.amount || row.amount <= 0)) {
|
||||
message.warning(t('transaction.validAmount'))
|
||||
return
|
||||
}
|
||||
aiRecordImporting.value = true
|
||||
try {
|
||||
const res: any = await batchCreateTransactions({
|
||||
records: selected.map((row) => ({
|
||||
categoryId: row.categoryId,
|
||||
type: row.type,
|
||||
amount: row.amount!,
|
||||
channel: row.channel || undefined,
|
||||
remark: row.remark || undefined,
|
||||
transactionTime: formatLocalDateTime(row.transactionTime),
|
||||
includeInStats: row.includeInStats,
|
||||
tagIds: row.tagIds,
|
||||
rawData: row.rawData || undefined,
|
||||
imageId: row.imageId ?? undefined,
|
||||
})),
|
||||
})
|
||||
message.success(t('ai.record.importSuccess', { count: res.data ?? selected.length }))
|
||||
showAiRecordResultModal.value = false
|
||||
await fetchData()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('ai.record.importFailed'))
|
||||
} finally {
|
||||
aiRecordImporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 图片记账结果表格列(类型、金额、分类、时间、备注可编辑) */
|
||||
const aiRecordColumns = computed<DataTableColumns<AiRecordEditRow>>(() => [
|
||||
{ type: 'selection' },
|
||||
{
|
||||
title: t('ai.record.col.source'),
|
||||
key: 'imageIndex',
|
||||
width: 80,
|
||||
render: (row) => (row.imageIndex ? t('ai.record.sourceImageLabel', { index: row.imageIndex }) : '-'),
|
||||
},
|
||||
{
|
||||
title: t('ai.record.col.duplicate'),
|
||||
key: 'duplicate',
|
||||
width: 90,
|
||||
render: (row) => (row.duplicate
|
||||
? h(NTag, { type: 'warning', size: 'small' }, { default: () => t('ai.record.duplicate') })
|
||||
: '-'),
|
||||
},
|
||||
{
|
||||
title: t('ai.record.col.type'),
|
||||
key: 'type',
|
||||
width: 100,
|
||||
render: (row) => h(NSelect, {
|
||||
value: row.type,
|
||||
options: typeOptions.value,
|
||||
size: 'small',
|
||||
onUpdateValue: (value: string) => { row.type = value; row.categoryId = null },
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: t('ai.record.col.amount'),
|
||||
key: 'amount',
|
||||
width: 120,
|
||||
render: (row) => h(NInputNumber, {
|
||||
value: row.amount,
|
||||
min: 0.01,
|
||||
precision: 2,
|
||||
size: 'small',
|
||||
onUpdateValue: (value: number | null) => { row.amount = value },
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: t('ai.record.col.category'),
|
||||
key: 'categoryId',
|
||||
width: 170,
|
||||
render: (row) => h(NSelect, {
|
||||
value: row.categoryId,
|
||||
options: categories.value.filter((c) => c.type === row.type),
|
||||
renderLabel: renderCategoryLabel,
|
||||
filterable: true,
|
||||
clearable: true,
|
||||
size: 'small',
|
||||
onUpdateValue: (value: number | null) => { row.categoryId = value },
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: t('ai.record.col.time'),
|
||||
key: 'transactionTime',
|
||||
width: 180,
|
||||
render: (row) => h(NDatePicker, {
|
||||
value: row.transactionTime,
|
||||
type: 'datetime',
|
||||
size: 'small',
|
||||
onUpdateValue: (value: number | null) => { row.transactionTime = value ?? row.transactionTime },
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: t('ai.record.col.remark'),
|
||||
key: 'remark',
|
||||
render: (row) => h(NInput, {
|
||||
value: row.remark,
|
||||
size: 'small',
|
||||
onUpdateValue: (value: string) => { row.remark = value },
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: t('ai.record.col.rawData'),
|
||||
key: 'rawData',
|
||||
width: 110,
|
||||
render: (row) => (row.rawData
|
||||
? h(NButton, { size: 'small', quaternary: true, type: 'primary', onClick: () => handleViewAiRecordRaw(row) },
|
||||
{ default: () => t('transaction.rawData') })
|
||||
: '-'),
|
||||
},
|
||||
])
|
||||
|
||||
/** 打开 AI 智能查询弹窗 */
|
||||
function handleAiQueryOpen() {
|
||||
if (!aiEnabled.value) {
|
||||
@@ -1517,6 +2044,12 @@ onMounted(() => {
|
||||
fetchTags()
|
||||
loadChannels()
|
||||
fetchData()
|
||||
// 监听剪贴板粘贴,支持在图片记账弹窗中直接粘贴图片
|
||||
window.addEventListener('paste', handleAiRecordPaste)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('paste', handleAiRecordPaste)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1534,6 +2067,7 @@ onMounted(() => {
|
||||
{{ t('transaction.batchDelete') }}
|
||||
</NButton>
|
||||
<NButton @click="showImportModal = true">{{ t('transaction.importBill') }}</NButton>
|
||||
<NButton @click="goChannelManage">{{ t('channel.manageEntry') }}</NButton>
|
||||
<NButton :loading="exporting" @click="handleExport">{{ t('transaction.exportExcel') }}</NButton>
|
||||
<NButton :loading="loading" @click="fetchData">
|
||||
<template #icon>
|
||||
@@ -1739,10 +2273,10 @@ onMounted(() => {
|
||||
<NSelect v-model:value="form.type" :options="typeOptions" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('transaction.column.category')">
|
||||
<div style="display: flex; gap: 8px; width: 100%;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; width: 100%;">
|
||||
<NSelect v-model:value="form.categoryId" :options="categories.filter(c => c.type === form.type)" :render-label="renderCategoryLabel" :placeholder="t('transaction.selectCategoryPlaceholder')" filterable style="flex: 1;" />
|
||||
<NButton v-if="aiEnabled" size="small" :loading="aiFormLoading" @click="handleAiSuggestOne">{{ t('ai.pickButton') }}</NButton>
|
||||
<NButton v-if="aiEnabled" size="small" :loading="aiNewLoading" @click="handleAiNewCategory">{{ t('ai.newCategory.button') }}</NButton>
|
||||
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiFormLoading" @click="handleAiSuggestOne">{{ t('ai.pickButton') }}</NButton>
|
||||
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiNewLoading" @click="handleAiNewCategory">{{ t('ai.newCategory.button') }}</NButton>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<!-- AI 生成的新分类确认条:用户确认后才保存 -->
|
||||
@@ -1756,8 +2290,19 @@ onMounted(() => {
|
||||
<NInputNumber v-model:value="form.amount" :min="0.01" :precision="2" style="width: 100%;" :placeholder="t('transaction.amountPlaceholder')" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('transaction.column.channel')">
|
||||
<NSelect v-model:value="form.channel" :options="channelOptions" clearable :placeholder="t('transaction.channelPlaceholder')" />
|
||||
<div style="display: flex; align-items: center; gap: 8px; width: 100%;">
|
||||
<NSelect v-model:value="form.channel" :options="channelOptions" clearable :placeholder="t('transaction.channelPlaceholder')" style="flex: 1;" />
|
||||
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiChannelLoading" @click="handleAiChannelSuggest">{{ t('ai.pickButton') }}</NButton>
|
||||
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiChannelNewLoading" @click="handleAiNewChannel">{{ t('ai.newChannel.button') }}</NButton>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<!-- AI 生成的新渠道确认条:用户确认后才保存 -->
|
||||
<div v-if="pendingChannel" style="display: flex; align-items: center; gap: 8px; margin-bottom: 18px; padding: 8px 12px; background: #f7f7fa; border-radius: 4px; font-size: 13px;">
|
||||
<span>{{ t('ai.newChannel.hint') }}</span>
|
||||
<span style="font-weight: 600;">{{ pendingChannel.name }}</span>
|
||||
<NButton size="tiny" type="primary" :loading="aiChannelNewSaving" @click="handleSaveNewChannel">{{ t('ai.newChannel.save') }}</NButton>
|
||||
<NButton size="tiny" @click="pendingChannel = null">{{ t('common.cancel') }}</NButton>
|
||||
</div>
|
||||
<NFormItem :label="t('transaction.column.transactionTime')">
|
||||
<NDatePicker v-model:value="form.transactionTime" type="datetime" style="width: 100%;" />
|
||||
</NFormItem>
|
||||
@@ -1765,7 +2310,7 @@ onMounted(() => {
|
||||
<NInput v-model:value="form.remark" type="textarea" :placeholder="t('transaction.remarkPlaceholder')" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('transaction.column.tags')">
|
||||
<div style="display: flex; gap: 8px; width: 100%;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; width: 100%;">
|
||||
<NSelect
|
||||
v-model:value="form.tagIds"
|
||||
:options="tagFilterOptions"
|
||||
@@ -1775,8 +2320,8 @@ onMounted(() => {
|
||||
:placeholder="t('transaction.tagPlaceholder')"
|
||||
style="flex: 1;"
|
||||
/>
|
||||
<NButton v-if="aiEnabled" size="small" :loading="aiTagSuggestLoading" @click="handleAiTagSuggest">{{ t('ai.pickButton') }}</NButton>
|
||||
<NButton v-if="aiEnabled" size="small" :loading="aiTagNewLoading" @click="handleAiNewTag">{{ t('ai.newTag.button') }}</NButton>
|
||||
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiTagSuggestLoading" @click="handleAiTagSuggest">{{ t('ai.pickButton') }}</NButton>
|
||||
<NButton v-if="aiEnabled" style="flex-shrink: 0;" :loading="aiTagNewLoading" @click="handleAiNewTag">{{ t('ai.newTag.button') }}</NButton>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<!-- AI 生成的新标签确认条:用户确认后才保存 -->
|
||||
@@ -1802,6 +2347,23 @@ onMounted(() => {
|
||||
<NInput :value="rawDataText" type="textarea" readonly :autosize="{ minRows: 8, maxRows: 20 }" />
|
||||
</NModal>
|
||||
|
||||
<!-- 来源图片查看弹窗(图片记账生成的记录):默认加载缩略图,可切换原图 -->
|
||||
<NModal v-model:show="showImageViewModal" preset="card" :title="t('transaction.sourceImages')" style="width: 720px; max-width: 92vw;" @after-leave="closeImageView">
|
||||
<NSpin :show="imageViewLoading">
|
||||
<NSpace vertical :size="12" style="min-height: 60px;">
|
||||
<div v-for="item in imageViewItems" :key="item.id" class="image-view-item">
|
||||
<img :src="item.url" alt="source image" style="width: 100%; max-height: 70vh; object-fit: contain; border-radius: 6px;" />
|
||||
<div class="image-view-actions">
|
||||
<NTag v-if="item.isOriginal" size="small" type="success">{{ t('transaction.originalImage') }}</NTag>
|
||||
<NButton v-else size="small" :loading="item.loading" @click="loadOriginalImage(item)">
|
||||
{{ t('transaction.loadOriginal') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</NSpace>
|
||||
</NSpin>
|
||||
</NModal>
|
||||
|
||||
<!-- 一键设置弹窗(作用于当前表格展示的记录) -->
|
||||
<NModal v-model:show="showBatchModal" preset="card" :title="t('transaction.batchTitle')" style="width: 480px;">
|
||||
<NForm>
|
||||
@@ -1871,19 +2433,96 @@ onMounted(() => {
|
||||
</NSpace>
|
||||
</NModal>
|
||||
|
||||
<!-- AI 智能记账弹窗 -->
|
||||
<NModal v-if="aiEnabled" v-model:show="showAiRecordModal" preset="card" :title="t('ai.record.title')" style="width: 520px; max-width: 92vw;">
|
||||
<!-- AI 智能记账弹窗(文字 / 图片) -->
|
||||
<NModal v-if="aiEnabled" v-model:show="showAiRecordModal" preset="card" :title="t('ai.record.title')" style="width: 560px; max-width: 92vw;">
|
||||
<NTabs v-model:value="aiRecordTab" type="line" animated>
|
||||
<NTabPane name="text" :tab="t('ai.record.textTab')">
|
||||
<NSpace vertical :size="12">
|
||||
<NAlert type="info" :show-icon="true">{{ t('ai.record.hint') }}</NAlert>
|
||||
<NInput
|
||||
v-model:value="aiRecordText"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 6 }"
|
||||
:placeholder="t('ai.record.placeholder')"
|
||||
/>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="showAiRecordModal = false">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="aiRecording" @click="handleAiRecordParse">{{ t('ai.record.parse') }}</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NTabPane>
|
||||
<NTabPane name="image" :tab="t('ai.record.imageTab')">
|
||||
<NSpace vertical :size="12">
|
||||
<NAlert type="info" :show-icon="true">{{ t('ai.record.imageHint') }}</NAlert>
|
||||
<NUpload
|
||||
:file-list="aiRecordFileList"
|
||||
:default-upload="false"
|
||||
:max="MAX_AI_RECORD_IMAGES"
|
||||
multiple
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
@change="handleAiRecordFileChange"
|
||||
>
|
||||
<NUploadDragger>
|
||||
<NIcon size="36" :component="CloudUploadOutline" />
|
||||
<div style="margin-top: 8px;">{{ t('ai.record.upload') }}</div>
|
||||
<div style="margin-top: 4px; font-size: 12px; color: #999;">{{ t('ai.record.uploadTip') }}</div>
|
||||
<div style="margin-top: 4px; font-size: 12px; color: #999;">{{ t('ai.record.pasteHint') }}</div>
|
||||
</NUploadDragger>
|
||||
</NUpload>
|
||||
<NInput v-model:value="aiRecordHint" :placeholder="t('ai.record.placeholder')" />
|
||||
<!-- 识别进度实时日志(SSE 推送) -->
|
||||
<div v-if="aiRecordProgress.length > 0" class="ai-progress">
|
||||
<div class="ai-progress-title">
|
||||
<NIcon :component="SparklesOutline" />
|
||||
<span>{{ t('ai.record.progressTitle') }}</span>
|
||||
</div>
|
||||
<div class="ai-progress-list">
|
||||
<div
|
||||
v-for="(item, index) in aiRecordProgress"
|
||||
:key="index"
|
||||
class="ai-progress-item"
|
||||
:class="{ 'is-running': index === aiRecordProgress.length - 1 && aiRecordingImage }"
|
||||
>
|
||||
<NIcon
|
||||
class="ai-progress-icon"
|
||||
:component="index === aiRecordProgress.length - 1 && aiRecordingImage ? RefreshOutline : CheckmarkCircleOutline"
|
||||
/>
|
||||
<span class="ai-progress-time">{{ item.time }}</span>
|
||||
<span class="ai-progress-text">{{ item.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="handleAiRecordCancel">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="aiRecordingImage" :disabled="aiRecordFiles.length === 0 || aiRecordingImage" @click="handleAiRecordParseImage">
|
||||
{{ t('ai.record.parseImage') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
</NModal>
|
||||
|
||||
<!-- 图片记账结果弹窗 -->
|
||||
<NModal v-if="aiEnabled" v-model:show="showAiRecordResultModal" preset="card" :title="t('ai.record.resultTitle')" style="width: 960px; max-width: 96vw;">
|
||||
<NSpace vertical :size="12">
|
||||
<NAlert type="info" :show-icon="true">{{ t('ai.record.hint') }}</NAlert>
|
||||
<NInput
|
||||
v-model:value="aiRecordText"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 6 }"
|
||||
:placeholder="t('ai.record.placeholder')"
|
||||
<NAlert type="info" :show-icon="true">
|
||||
{{ aiRecordSummary || t('ai.record.resultHint') }}
|
||||
</NAlert>
|
||||
<NDataTable
|
||||
v-model:checked-row-keys="checkedAiRecordKeys"
|
||||
:columns="aiRecordColumns"
|
||||
:data="aiRecordRows"
|
||||
:row-key="(row) => row.key"
|
||||
:max-height="420"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
/>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="showAiRecordModal = false">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="aiRecording" @click="handleAiRecordParse">{{ t('ai.record.parse') }}</NButton>
|
||||
<NButton @click="showAiRecordResultModal = false">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="aiRecordImporting" :disabled="checkedAiRecordKeys.length === 0" @click="handleAiRecordImport">
|
||||
{{ t('ai.record.importSelected', { count: checkedAiRecordKeys.length }) }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NModal>
|
||||
@@ -1962,4 +2601,69 @@ onMounted(() => {
|
||||
:deep(.excluded-row .remark-text) {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
/* 图片识别进度:浅色卡片 + 步骤列表 */
|
||||
.image-view-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.image-view-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.ai-progress {
|
||||
background: #f7f9fc;
|
||||
border: 1px solid #eef1f6;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.ai-progress-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
.ai-progress-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ai-progress-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #555;
|
||||
}
|
||||
.ai-progress-icon {
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
color: #18a058;
|
||||
}
|
||||
/* 最新一条处理中时用蓝色旋转图标 */
|
||||
.ai-progress-item.is-running .ai-progress-icon {
|
||||
color: #2080f0;
|
||||
animation: ai-progress-spin 1s linear infinite;
|
||||
}
|
||||
.ai-progress-time {
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #a0a4ab;
|
||||
}
|
||||
.ai-progress-text {
|
||||
word-break: break-all;
|
||||
}
|
||||
@keyframes ai-progress-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user