feat: AI 图片记账、自定义交易渠道与淘宝/京东账单导入
- AI 图片记账:多图上传由视觉模型识别,SSE 实时推送进度,提取 rawData,按「类型+金额+时间」检测重复,支持缩略图与来源图片查看 - 自定义交易渠道:新增 t_channel 与渠道管理页,支持新建/改名/删除,并接入统计、导出与 AI 查询/记账/建议提示词 - AI 建议:单条建议新增交易渠道推荐(推荐已有渠道 / AI 新建渠道) - 账单导入:新增淘宝(xlsx)、京东(CSV)解析器 - 交易:新增批量创建接口,导入时按去重键兜底跳过重复 - 运行目录统一经 AppHome 解析,图片与数据文件落盘规范化 - 前端:AI 记账方式偏好记忆、AI 按钮与输入框对齐、来源图片弹窗支持加载原图
This commit is contained in:
+47
-1
@@ -8,6 +8,7 @@ export interface AiConfig {
|
||||
hasApiKey: boolean
|
||||
baseUrl: string
|
||||
model: string
|
||||
visionModel: string
|
||||
}
|
||||
|
||||
/** AI 建议新建的标签 */
|
||||
@@ -27,6 +28,8 @@ export interface AiSuggestion {
|
||||
newCategoryIcon: string | null
|
||||
tagIds: number[]
|
||||
tagNames: string[]
|
||||
channel: string | null
|
||||
channelName: string | null
|
||||
newTags: AiNewTag[]
|
||||
reason: string
|
||||
}
|
||||
@@ -37,7 +40,7 @@ export function getAiConfig() {
|
||||
}
|
||||
|
||||
/** 保存 AI 配置,apiKey 留空表示不修改原值 */
|
||||
export function saveAiConfig(data: { enabled: boolean; apiKey?: string; baseUrl?: string; model?: string }) {
|
||||
export function saveAiConfig(data: { enabled: boolean; apiKey?: string; baseUrl?: string; model?: string; visionModel?: string }) {
|
||||
return request.put('/ai/config', data)
|
||||
}
|
||||
|
||||
@@ -77,6 +80,17 @@ export function parseTagByAi(text: string) {
|
||||
return request.post('/ai/tag', { text }, { timeout: 60000 })
|
||||
}
|
||||
|
||||
/** AI 生成的交易渠道建议 */
|
||||
export interface AiChannelResult {
|
||||
name: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
/** 根据自然语言描述生成交易渠道(调用大模型,超时放宽) */
|
||||
export function parseChannelByAi(text: string) {
|
||||
return request.post('/ai/channel', { text }, { timeout: 60000 })
|
||||
}
|
||||
|
||||
/** 根据表单当前内容生成单条分类与标签建议(调用大模型,超时放宽) */
|
||||
export function suggestOneByAi(data: { type: string; amount?: number | null; channel?: string; remark?: string }) {
|
||||
return request.post('/ai/suggest-one', data, { timeout: 60000 })
|
||||
@@ -213,6 +227,14 @@ export interface AiRecordResult {
|
||||
tagIds: number[]
|
||||
tagNames: string[]
|
||||
unmatched: string[]
|
||||
/** 图片记账时由模型提取的原始字段 JSON 文本,文本记账为 null */
|
||||
rawData: string | null
|
||||
/** 来源图片序号(图片记账,从 1 开始),文本记账为 null */
|
||||
imageIndex: number | null
|
||||
/** 来源图片ID(图片记账),文本记账为 null */
|
||||
imageId: number | null
|
||||
/** 是否疑似重复:与已有记录或本批其它记录的类型、金额、时间一致 */
|
||||
duplicate: boolean | null
|
||||
}
|
||||
|
||||
/** 将自然语言记账内容解析为交易记录 */
|
||||
@@ -220,6 +242,30 @@ export function parseRecordByAi(text: string) {
|
||||
return request.post('/ai/record', { text }, { timeout: 120000 })
|
||||
}
|
||||
|
||||
/** 图片记账解析结果 */
|
||||
export interface AiRecordImageResult {
|
||||
/** 保存的图片ID列表,按上传顺序排列 */
|
||||
imageIds: number[]
|
||||
summary: string
|
||||
records: AiRecordResult[]
|
||||
unmatched: string[]
|
||||
}
|
||||
|
||||
/** 上传一至多张账单/支付截图,由 AI 一次性识别并生成多条交易记录(调用视觉模型,超时放宽) */
|
||||
export function parseRecordByImage(files: File[], hint?: string) {
|
||||
const formData = new FormData()
|
||||
for (const file of files) {
|
||||
formData.append('files', file)
|
||||
}
|
||||
if (hint) formData.append('hint', hint)
|
||||
return request.post('/ai/record/image', formData, { timeout: 180000 })
|
||||
}
|
||||
|
||||
/** 获取 AI 记账图片(blob,经鉴权头请求,避免 token 出现在 URL;thumbnail=true 返回压缩缩略图,默认原图) */
|
||||
export function getAiImageBlob(id: number, thumbnail = false) {
|
||||
return request.get(`/ai/image/${id}${thumbnail ? '?thumbnail=true' : ''}`, { responseType: 'blob', timeout: 30000 })
|
||||
}
|
||||
|
||||
/** AI 使用累计汇总 */
|
||||
export interface AiUsageSummary {
|
||||
totalCalls: number
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// 用户自定义交易渠道相关 API 接口:增删改查
|
||||
import request from './request'
|
||||
|
||||
/** 自定义渠道数据结构 */
|
||||
export interface ChannelItem {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
transactionCount: number
|
||||
}
|
||||
|
||||
/** 查询当前用户的自定义渠道列表(含使用账单数) */
|
||||
export function getChannelList() {
|
||||
return request.get('/channel')
|
||||
}
|
||||
|
||||
/** 新建自定义渠道 */
|
||||
export function createChannel(name: string) {
|
||||
return request.post('/channel', { name })
|
||||
}
|
||||
|
||||
/** 重命名自定义渠道 */
|
||||
export function renameChannel(id: number, name: string) {
|
||||
return request.put(`/channel/${id}`, { name })
|
||||
}
|
||||
|
||||
/** 删除自定义渠道(存在账单使用时后端会拒绝) */
|
||||
export function deleteChannel(id: number) {
|
||||
return request.delete(`/channel/${id}`)
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
// 收支记录相关 API 接口:交易记录的增删改查(分页)
|
||||
import request from './request'
|
||||
import { setCustomChannels } from '../utils/channel'
|
||||
|
||||
/** 获取系统支持的交易渠道编码列表(含账单导入来源与人工交易渠道) */
|
||||
/** 获取交易渠道列表(内置 + 当前用户自定义),并注册自定义渠道名称供展示解析 */
|
||||
export function getChannels() {
|
||||
return request.get('/transaction/channels')
|
||||
return request.get('/transaction/channels').then((res: any) => {
|
||||
setCustomChannels(res.data || [])
|
||||
return res
|
||||
})
|
||||
}
|
||||
|
||||
/** 分页获取交易记录列表,支持按类型、分类、交易渠道、时间范围、计入统计、金额区间多选筛选 */
|
||||
@@ -43,6 +47,25 @@ export function createTransaction(data: {
|
||||
return request.post('/transaction', data)
|
||||
}
|
||||
|
||||
/** 批量创建交易记录,可关联 AI 记账图片 */
|
||||
export function batchCreateTransactions(data: {
|
||||
imageId?: number | null
|
||||
records: {
|
||||
categoryId?: number | null
|
||||
type: string
|
||||
amount: number
|
||||
channel?: string
|
||||
remark?: string
|
||||
transactionTime: string
|
||||
includeInStats?: boolean
|
||||
tagIds?: number[]
|
||||
rawData?: string | null
|
||||
imageId?: number | null
|
||||
}[]
|
||||
}) {
|
||||
return request.post('/transaction/batch', data, { timeout: 120000 })
|
||||
}
|
||||
|
||||
/** 更新指定交易记录(categoryId 为空表示不设置分类) */
|
||||
export function updateTransaction(id: number, data: {
|
||||
categoryId: number | null
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
useMessage, NCard, NForm, NFormItem, NInput, NSwitch, NButton, NSpace, NAlert, NText, NTag, NIcon,
|
||||
} from 'naive-ui'
|
||||
import {
|
||||
SettingsOutline, KeyOutline, LinkOutline, CubeOutline, SaveOutline, FlashOutline, StatsChartOutline,
|
||||
SettingsOutline, KeyOutline, LinkOutline, CubeOutline, SaveOutline, FlashOutline, StatsChartOutline, CameraOutline,
|
||||
} from '@vicons/ionicons5'
|
||||
import { getAiConfig, saveAiConfig, testAiConfig } from '../api/ai'
|
||||
|
||||
@@ -20,7 +20,7 @@ const testing = ref(false)
|
||||
const hasApiKey = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const form = ref({ enabled: false, apiKey: '', baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat' })
|
||||
const form = ref({ enabled: false, apiKey: '', baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat', visionModel: 'deepseek-flash' })
|
||||
|
||||
/** 加载当前用户的 AI 配置 */
|
||||
async function loadConfig() {
|
||||
@@ -32,6 +32,7 @@ async function loadConfig() {
|
||||
apiKey: '',
|
||||
baseUrl: data.baseUrl || 'https://api.deepseek.com',
|
||||
model: data.model || 'deepseek-chat',
|
||||
visionModel: data.visionModel || 'deepseek-flash',
|
||||
}
|
||||
hasApiKey.value = !!data.hasApiKey
|
||||
} catch (e: any) {
|
||||
@@ -46,6 +47,7 @@ function buildPayload() {
|
||||
apiKey: form.value.apiKey || undefined,
|
||||
baseUrl: form.value.baseUrl,
|
||||
model: form.value.model,
|
||||
visionModel: form.value.visionModel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +147,13 @@ onMounted(loadConfig)
|
||||
</template>
|
||||
</NInput>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('ai.visionModel')">
|
||||
<NInput v-model:value="form.visionModel" placeholder="deepseek-flash">
|
||||
<template #prefix>
|
||||
<NIcon :component="CameraOutline" />
|
||||
</template>
|
||||
</NInput>
|
||||
</NFormItem>
|
||||
<NFormItem>
|
||||
<NSpace justify="end" style="width: 100%;">
|
||||
<NButton type="primary" :loading="saving" @click="handleSave">
|
||||
|
||||
@@ -39,6 +39,8 @@ export default {
|
||||
cash: { zh: '现金', en: 'Cash', ja: '現金' },
|
||||
bank: { zh: '银行卡', en: 'Bank Card', ja: '銀行カード' },
|
||||
meituan: { zh: '美团', en: 'Meituan', ja: 'Meituan' },
|
||||
taobao: { zh: '淘宝', en: 'Taobao', ja: 'Taobao' },
|
||||
jd: { zh: '京东', en: 'JD', ja: 'JD' },
|
||||
cmb: { zh: '招商银行', en: 'China Merchants Bank', ja: '招商銀行' },
|
||||
bocom: { zh: '交通银行', en: 'Bank of Communications', ja: '交通銀行' },
|
||||
},
|
||||
@@ -404,6 +406,10 @@ export default {
|
||||
remarkUnhide: { zh: '取消隐藏', en: 'Unhide', ja: '非表示を解除' },
|
||||
rawData: { zh: '原始数据', en: 'Raw Data', ja: '元データ' },
|
||||
rawDataTitle: { zh: '原始账单数据', en: 'Raw Bill Data', ja: '元の明細データ' },
|
||||
sourceImages: { zh: '来源图片', en: 'Source Images', ja: '元の画像' },
|
||||
viewImages: { zh: '查看({count})', en: 'View ({count})', ja: '表示({count})' },
|
||||
loadOriginal: { zh: '加载原图', en: 'Load Original', ja: '元画像を読み込む' },
|
||||
originalImage: { zh: '原图', en: 'Original', ja: '元画像' },
|
||||
createdTime: { zh: '创建时间:{time}', en: 'Created: {time}', ja: '作成日時:{time}' },
|
||||
editRecord: { zh: '编辑记录', en: 'Edit Record', ja: '記録を編集' },
|
||||
addRecordTitle: { zh: '新增记录', en: 'Add Record', ja: '記録を追加' },
|
||||
@@ -492,6 +498,23 @@ export default {
|
||||
deleteConfirm: { zh: '删除后将从所有交易记录中移除该标签,确定删除吗?', en: 'This tag will be removed from all transactions. Delete it?', ja: '削除するとすべての取引記録からこのタグが外れます。削除してもよろしいですか?' },
|
||||
},
|
||||
|
||||
// 交易渠道管理(用户自定义)
|
||||
channel: {
|
||||
title: { zh: '交易渠道管理', en: 'Channel Management', ja: '取引チャネル管理' },
|
||||
add: { zh: '新建渠道', en: 'New Channel', ja: 'チャネルを追加' },
|
||||
addTitle: { zh: '新建渠道', en: 'New Channel', ja: 'チャネルを追加' },
|
||||
editTitle: { zh: '重命名渠道', en: 'Rename Channel', ja: 'チャネル名を変更' },
|
||||
name: { zh: '渠道名称', en: 'Channel name', ja: 'チャネル名' },
|
||||
namePlaceholder: { zh: '请输入渠道名称', en: 'Enter channel name', ja: 'チャネル名を入力してください' },
|
||||
nameRequired: { zh: '请输入渠道名称', en: 'Please enter a channel name', ja: 'チャネル名を入力してください' },
|
||||
usage: { zh: '使用账单数', en: 'Used by', ja: '使用件数' },
|
||||
usageCount: { zh: '{count} 笔账单', en: '{count} record(s)', ja: '{count} 件' },
|
||||
unused: { zh: '未使用', en: 'Unused', ja: '未使用' },
|
||||
deleteConfirm: { zh: '确定删除渠道「{name}」吗?', en: 'Delete channel "{name}"?', ja: 'チャネル「{name}」を削除しますか?' },
|
||||
hint: { zh: '自定义渠道可用于记账与筛选;删除前需确保没有任何账单使用该渠道,改名不影响历史账单。', en: 'Custom channels can be used in entries and filters. A channel can only be deleted when no transaction uses it; renaming does not affect existing records.', ja: 'カスタムチャネルは記帳や絞り込みに利用できます。取引で使用中のチャネルは削除できません。名前の変更は既存の記録に影響しません。' },
|
||||
manageEntry: { zh: '渠道管理', en: 'Channels', ja: 'チャネル管理' },
|
||||
},
|
||||
|
||||
// 规则管理
|
||||
rule: {
|
||||
title: { zh: '规则管理', en: 'Rules', ja: 'ルール管理' },
|
||||
@@ -532,6 +555,8 @@ export default {
|
||||
billTypeALIPAY: { zh: '支付宝', en: 'Alipay', ja: 'Alipay' },
|
||||
billTypeWECHAT: { zh: '微信', en: 'WeChat', ja: 'WeChat' },
|
||||
billTypeMEITUAN: { zh: '美团', en: 'Meituan', ja: 'Meituan' },
|
||||
billTypeTAOBAO: { zh: '淘宝', en: 'Taobao', ja: 'Taobao' },
|
||||
billTypeJD: { zh: '京东', en: 'JD', ja: 'JD' },
|
||||
billTypeCMB: { zh: '招商银行', en: 'China Merchants Bank', ja: '招商銀行' },
|
||||
billTypeBOCOM: { zh: '交通银行', en: 'Bank of Communications', ja: '交通銀行' },
|
||||
billFile: { zh: '账单文件:', en: 'Bill file:', ja: '明細ファイル:' },
|
||||
@@ -541,6 +566,8 @@ export default {
|
||||
exportHintALIPAY: { zh: '导出路径:支付宝 App → 我的 → 账单 → 开具交易流水证明 / 账单导出,选择 CSV 格式。', en: 'Export path: Alipay App → Me → Bills → Issue Transaction Record / Export Bill, choose CSV format.', ja: 'エクスポート手順:Alipay アプリ → マイ → 請求 → 取引履歴証明の発行 / 明細のエクスポート で CSV 形式を選択。' },
|
||||
exportHintWECHAT: { zh: '导出路径:微信 App → 我 → 服务 → 钱包 → 账单 → 常见问题 → 下载账单 → 用于个人对账,选择 xlsx 格式。', en: 'Export path: WeChat App → Me → Services → Wallet → Bills → FAQ → Download Bills → For personal reconciliation, choose xlsx format.', ja: 'エクスポート手順:WeChat アプリ → 自分 → サービス → ウォレット → 請求 → よくある質問 → 請求書のダウンロード → 個人の照合用 で xlsx 形式を選択。' },
|
||||
exportHintMEITUAN: { zh: '导出路径:美团 App → 我的 → 钱包 → 账单 → 开具账单证明(申请记录),选择 CSV 格式并设置解压密码。', en: 'Export path: Meituan App → Me → Wallet → Bills → Issue Bill Certificate (Application Record), choose CSV format and set an archive password.', ja: 'エクスポート手順:Meituan アプリ → マイ → ウォレット → 請求 → 請求証明の発行(申請履歴)で CSV 形式を選択し、解凍パスワードを設定。' },
|
||||
exportHintTAOBAO: { zh: '导出路径:淘宝网页版 → 我的淘宝 → 我的订单 → 已买到的宝贝 → 导出订单,下载 xlsx。', en: 'Export path: Taobao web → My Taobao → My Orders → Purchased Items → Export Orders, download as xlsx.', ja: 'エクスポート手順:Taobao ウェブ版 → マイ淘宝 → マイ注文 → 購入済み商品 → 注文をエクスポート で xlsx をダウンロード。' },
|
||||
exportHintJD: { zh: '导出路径:京东 App → 我的 → 设置 → 账单 → 右上角「…」→ 账单导出,设置接收邮箱后提交,从邮件下载 CSV(解压密码见邮件说明)。', en: 'Export path: JD App → Me → Settings → Bills → top-right "…" → Bill Export, set a receiving email and submit, then download the CSV from the email (archive password is in the email).', ja: 'エクスポート手順:JD アプリ → マイ → 設定 → 請求 → 右上の「…」→ 請求エクスポート で受信メールを設定して送信し、メールから CSV をダウンロード(解凍パスワードはメールに記載)。' },
|
||||
exportHintCMB: { zh: '导出路径:招商银行 App → 我的 → 全部 → 交易流水,支持 CSV 或 PDF 格式导出(如需压缩包请设置解压密码)。', en: 'Export path: China Merchants Bank App → Me → All → Transaction Records; CSV or PDF is supported (set an archive password if zipped).', ja: 'エクスポート手順:招商銀行アプリ → マイ → すべて → 取引明細。CSV または PDF 形式に対応(圧縮する場合は解凍パスワードを設定)。' },
|
||||
exportHintBOCOM: { zh: '导出路径:交通银行 App → 我的 → 银行卡 → 交易明细,选择查询时间段后导出 Excel(xls)。', en: 'Export path: Bank of Communications App → Me → Bank Cards → Transaction Details, choose a date range and export Excel (xls).', ja: 'エクスポート手順:交通銀行アプリ → マイ → 銀行カード → 取引明細 で期間を選択し Excel(xls)でエクスポート。' },
|
||||
parse: { zh: '解析', en: 'Parse', ja: '解析' },
|
||||
@@ -608,6 +635,7 @@ export default {
|
||||
apiKeyKeep: { zh: '已配置,留空表示不修改', en: 'Configured; leave blank to keep unchanged', ja: '設定済み。空欄のままにすると変更されません' },
|
||||
baseUrl: { zh: '接口地址', en: 'Base URL', ja: 'エンドポイント' },
|
||||
model: { zh: '模型', en: 'Model', ja: 'モデル' },
|
||||
visionModel: { zh: '视觉模型', en: 'Vision model', ja: 'ビジョンモデル' },
|
||||
save: { zh: '保存', en: 'Save', ja: '保存' },
|
||||
test: { zh: '测试连接', en: 'Test connection', ja: '接続テスト' },
|
||||
saveSuccess: { zh: '保存成功', en: 'Saved successfully', ja: '保存しました' },
|
||||
@@ -631,6 +659,8 @@ export default {
|
||||
aggregated: { zh: '已聚合为 {groups} 类、共 {count} 条记录', en: 'Aggregated into {groups} groups, {count} records in total', ja: '{groups} グループ、合計 {count} 件の記録に集約しました' },
|
||||
applySuccess: { zh: '已应用 {count} 条建议', en: 'Applied {count} suggestions', ja: '{count} 件の提案を適用しました' },
|
||||
oneApplied: { zh: '已应用 AI 推荐分类', en: 'AI category applied', ja: 'AI が提案したカテゴリを適用しました' },
|
||||
channelApplied: { zh: '已应用 AI 推荐渠道', en: 'AI channel applied', ja: 'AI が提案したチャネルを適用しました' },
|
||||
noChannelSuggestion: { zh: '未找到合适的渠道建议', en: 'No channel suggestion', ja: '適切なチャネル提案が見つかりません' },
|
||||
tagApplied: { zh: '已应用 AI 推荐标签', en: 'AI tags applied', ja: 'AI が提案したタグを適用しました' },
|
||||
applyFailed: { zh: '应用建议失败', en: 'Failed to apply suggestions', ja: '提案の適用に失敗しました' },
|
||||
applyHint: { zh: '将覆盖分类并追加标签;标记「新建」的分类/标签会在确认后创建', en: 'Categories will be overwritten and tags appended; items marked "New" will be created after confirmation.', ja: 'カテゴリは上書き、タグは追加されます。「新規」の項目は確定後に作成されます。' },
|
||||
@@ -682,6 +712,15 @@ export default {
|
||||
exists: { zh: '已存在同名分类,已为你选中', en: 'A same-name category exists and has been selected', ja: '同名のカテゴリが存在するため選択しました' },
|
||||
needInfo: { zh: '请先填写备注或金额,便于生成分类', en: 'Please fill in a remark or amount first', ja: 'カテゴリ生成のため、先に備考または金額を入力してください' },
|
||||
},
|
||||
newChannel: {
|
||||
button: { zh: 'AI 新建', en: 'AI New', ja: 'AI で新規作成' },
|
||||
hint: { zh: 'AI 建议新渠道:', en: 'AI suggests:', ja: 'AI の提案する新チャネル:' },
|
||||
save: { zh: '保存并选用', en: 'Save & Select', ja: '保存して選択' },
|
||||
saved: { zh: '已创建并选用新渠道', en: 'Channel created and selected', ja: '新しいチャネルを作成して選択しました' },
|
||||
exists: { zh: '已存在同名渠道,已为你选中', en: 'A same-name channel exists and has been selected', ja: '同名のチャネルが存在するため選択しました' },
|
||||
needInfo: { zh: '请先填写备注或金额,便于生成渠道', en: 'Please fill in a remark or amount first', ja: 'チャネル生成のため、先に備考または金額を入力してください' },
|
||||
failed: { zh: 'AI 未能生成渠道,请重试', en: 'AI failed to generate a channel', ja: 'AI がチャネルを生成できませんでした' },
|
||||
},
|
||||
newTag: {
|
||||
button: { zh: 'AI 新建', en: 'AI New', ja: 'AI で新規作成' },
|
||||
hint: { zh: 'AI 建议新标签:', en: 'AI suggests tag:', ja: 'AI の提案する新タグ:' },
|
||||
@@ -725,6 +764,40 @@ export default {
|
||||
parse: { zh: '解析', en: 'Parse', ja: '解析' },
|
||||
emptyInput: { zh: '请输入记账内容', en: 'Please enter the transaction description', ja: '記帳内容を入力してください' },
|
||||
failed: { zh: 'AI 记账解析失败', en: 'Failed to parse the transaction', ja: 'AI 記帳の解析に失敗しました' },
|
||||
textTab: { zh: '文字记账', en: 'Text Entry', ja: 'テキスト入力' },
|
||||
imageTab: { zh: '图片记账', en: 'Image Entry', ja: '画像入力' },
|
||||
imageHint: { zh: '上传一至多张账单或支付截图(最多 5 张),AI 会一次性识别其中的每一笔交易,确认后导入。', en: 'Upload one to several bill or payment screenshots (up to 5); AI recognizes every transaction in one pass for you to confirm and import.', ja: '請求書や支払いのスクリーンショットを1〜複数枚(最大5枚)アップロードすると、AI が各取引をまとめて認識します。確認して取り込んでください。' },
|
||||
upload: { zh: '上传图片(可多选)', en: 'Upload images (multiple)', ja: '画像をアップロード(複数可)' },
|
||||
uploadTip: { zh: '支持 JPEG/PNG/GIF/WebP,单张不超过 10MB,最多 5 张', en: 'JPEG/PNG/GIF/WebP, up to 10MB each, max 5 images', ja: 'JPEG/PNG/GIF/WebP、1枚10MBまで、最大5枚' },
|
||||
pasteHint: { zh: '也可直接按 Ctrl+V(Mac 为 ⌘V)粘贴剪贴板中的图片', en: 'You can also press Ctrl+V (⌘V on Mac) to paste an image from the clipboard', ja: 'Ctrl+V(Mac は ⌘V)でクリップボードの画像を貼り付けることもできます' },
|
||||
pasteSuccess: { zh: '已粘贴剪贴板中的图片', en: 'Pasted the image from clipboard', ja: 'クリップボードの画像を貼り付けました' },
|
||||
parseImage: { zh: '识别图片', en: 'Recognize', ja: '画像を解析' },
|
||||
recognizing: { zh: '正在识别图片,请稍候…', en: 'Recognizing, please wait…', ja: '画像を解析中です。しばらくお待ちください…' },
|
||||
progressTitle: { zh: '识别进度', en: 'Progress', ja: '進捗' },
|
||||
imageFailed: { zh: 'AI 图片记账解析失败', en: 'Failed to parse the image', ja: 'AI 画像記帳の解析に失敗しました' },
|
||||
noRecord: { zh: '未从图片中识别到有效交易', en: 'No valid transactions recognized from the image', ja: '画像から有効な取引を認識できませんでした' },
|
||||
resultTitle: { zh: '图片记账结果', en: 'Image Entry Result', ja: '画像記帳の結果' },
|
||||
resultHint: { zh: '请核对识别结果,勾选后导入;可直接编辑类型、金额、分类与备注。', en: 'Review the results, edit type/amount/category/remark, then import the selected ones.', ja: '認識結果を確認し、種別・金額・カテゴリ・備考を編集して、選択した項目を取り込んでください。' },
|
||||
importSelected: { zh: '导入勾选({count})', en: 'Import selected ({count})', ja: '選択項目を取り込む({count})' },
|
||||
importSuccess: { zh: '已导入 {count} 条记录', en: 'Imported {count} record(s)', ja: '{count} 件を取り込みました' },
|
||||
importFailed: { zh: '导入失败', en: 'Import failed', ja: '取り込みに失敗しました' },
|
||||
selectAtLeastOne: { zh: '请至少勾选一条记录', en: 'Please select at least one record', ja: '少なくとも1件の記録を選択してください' },
|
||||
viewImage: { zh: '查看原图', en: 'View image', ja: '元画像を表示' },
|
||||
col: {
|
||||
source: { zh: '来源图', en: 'Source', ja: '元画像' },
|
||||
duplicate: { zh: '重复', en: 'Duplicate', ja: '重複' },
|
||||
type: { zh: '类型', en: 'Type', ja: '種別' },
|
||||
amount: { zh: '金额', en: 'Amount', ja: '金額' },
|
||||
category: { zh: '分类', en: 'Category', ja: 'カテゴリ' },
|
||||
channel: { zh: '渠道', en: 'Channel', ja: 'チャネル' },
|
||||
time: { zh: '时间', en: 'Time', ja: '日時' },
|
||||
remark: { zh: '备注', en: 'Remark', ja: '備考' },
|
||||
tags: { zh: '标签', en: 'Tags', ja: 'タグ' },
|
||||
rawData: { zh: '原始数据', en: 'Raw Data', ja: '元データ' },
|
||||
},
|
||||
sourceImageLabel: { zh: '图 {index}', en: 'Image {index}', ja: '画像 {index}' },
|
||||
duplicate: { zh: '疑似重复', en: 'Duplicate', ja: '重複の疑い' },
|
||||
maxImages: { zh: '单次最多上传 {count} 张图片', en: 'Up to {count} images per upload', ja: '1回に最大 {count} 枚までアップロードできます' },
|
||||
},
|
||||
column: {
|
||||
record: { zh: '记录', en: 'Record', ja: '記録' },
|
||||
|
||||
@@ -58,6 +58,11 @@ const router = createRouter({
|
||||
name: 'Rules',
|
||||
component: () => import('../views/RuleManage.vue'), // 规则管理(自定义分类与标签规则)
|
||||
},
|
||||
{
|
||||
path: 'channels',
|
||||
name: 'Channels',
|
||||
component: () => import('../views/ChannelManage.vue'), // 交易渠道管理(独立页,不加入侧边栏)
|
||||
},
|
||||
{
|
||||
path: 'email-bills',
|
||||
// 跳转到上次访问的标签页,无记录时默认邮箱配置
|
||||
|
||||
@@ -1,13 +1,45 @@
|
||||
// 交易渠道相关工具:将后端返回的交易渠道编码映射为当前语言标签
|
||||
// 交易渠道相关工具:将后端返回的交易渠道编码映射为当前语言标签,并支持用户自定义渠道
|
||||
import i18n from '../locales'
|
||||
|
||||
/** i18n 全局实例的翻译函数签名(避免动态 key 触发过深的类型推断) */
|
||||
type Translate = (key: string) => string
|
||||
type HasKey = (key: string) => boolean
|
||||
|
||||
/** 将交易渠道编码转换为当前语言的标签,无对应文案时回退为原始编码 */
|
||||
/** 渠道选项结构(与后端 /transaction/channels 对应) */
|
||||
export interface ChannelOption {
|
||||
code: string
|
||||
name?: string | null
|
||||
builtin?: boolean
|
||||
}
|
||||
|
||||
/** 自定义渠道「编码 → 名称」映射,由渠道列表接口填充 */
|
||||
const customChannelMap = new Map<string, string>()
|
||||
|
||||
/**
|
||||
* 注册渠道选项:仅记录用户自定义渠道(builtin=false),内置渠道仍走多语言文案。
|
||||
*
|
||||
* @param list 渠道选项列表
|
||||
*/
|
||||
export function setCustomChannels(list: ChannelOption[]): void {
|
||||
customChannelMap.clear()
|
||||
for (const item of list || []) {
|
||||
if (item && item.builtin === false && item.name) {
|
||||
customChannelMap.set(item.code, item.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将交易渠道编码转换为当前语言的标签:
|
||||
* 自定义渠道取后端返回的名称,内置渠道取 i18n 文案,均无匹配时回退原始编码。
|
||||
*
|
||||
* @param val 渠道编码
|
||||
* @returns 渠道标签
|
||||
*/
|
||||
export function getChannelLabel(val: string): string {
|
||||
if (!val) return '-'
|
||||
const custom = customChannelMap.get(val)
|
||||
if (custom) return custom
|
||||
const { t, te } = i18n.global as unknown as { t: Translate; te: HasKey }
|
||||
const key = `common.channel.${val.toLowerCase()}`
|
||||
return te(key) ? t(key) : val
|
||||
|
||||
@@ -5,6 +5,10 @@ import { useUserStore } from '../store/user'
|
||||
|
||||
/** SSE 事件回调集合 */
|
||||
export interface SseOptions {
|
||||
/** 请求方法,默认 GET;POST 用于需要携带请求体(如上传文件)的流式接口 */
|
||||
method?: 'GET' | 'POST'
|
||||
/** 请求体,POST 时使用(FormData 由浏览器自动设置 Content-Type) */
|
||||
body?: BodyInit | null
|
||||
/** 收到事件时回调,event 为事件名,data 为已合并多行的数据 */
|
||||
onEvent: (event: string, data: string) => void
|
||||
/** 连接异常(含认证失败、网络错误)时回调 */
|
||||
@@ -17,18 +21,21 @@ export interface SseOptions {
|
||||
* 建立 SSE 连接。
|
||||
*
|
||||
* @param url 相对或绝对地址(不含 token 查询参数)
|
||||
* @param options 事件回调
|
||||
* @param options 事件回调与可选的请求方法与请求体
|
||||
* @returns 用于中断连接的 AbortController
|
||||
*/
|
||||
export function openSse(url: string, options: SseOptions): AbortController {
|
||||
const controller = new AbortController()
|
||||
const userStore = useUserStore()
|
||||
const { method = 'GET', body } = options
|
||||
|
||||
fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${userStore.token}`,
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
|
||||
@@ -32,6 +32,8 @@ const billTypeLabelKeys: Record<string, string> = {
|
||||
ALIPAY: 'import.billTypeALIPAY',
|
||||
WECHAT: 'import.billTypeWECHAT',
|
||||
MEITUAN: 'import.billTypeMEITUAN',
|
||||
TAOBAO: 'import.billTypeTAOBAO',
|
||||
JD: 'import.billTypeJD',
|
||||
CMB: 'import.billTypeCMB',
|
||||
BOCOM: 'import.billTypeBOCOM',
|
||||
}
|
||||
@@ -39,6 +41,8 @@ const billTypeExportHintKeys: Record<string, string> = {
|
||||
ALIPAY: 'import.exportHintALIPAY',
|
||||
WECHAT: 'import.exportHintWECHAT',
|
||||
MEITUAN: 'import.exportHintMEITUAN',
|
||||
TAOBAO: 'import.exportHintTAOBAO',
|
||||
JD: 'import.exportHintJD',
|
||||
CMB: 'import.exportHintCMB',
|
||||
BOCOM: 'import.exportHintBOCOM',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
// 交易渠道管理组件:展示用户自定义渠道,支持新增、改名、删除(被账单使用时不可删除)
|
||||
import { ref, onMounted, computed, h } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem, NInput, NAlert, NTag, NIcon } from 'naive-ui'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { ArrowBackOutline } from '@vicons/ionicons5'
|
||||
import { getChannelList, createChannel, renameChannel, deleteChannel } from '../api/channel'
|
||||
import type { ChannelItem } from '../api/channel'
|
||||
|
||||
const message = useMessage() // 消息提示
|
||||
const dialog = useDialog() // 对话框
|
||||
const router = useRouter() // 路由
|
||||
const { t } = useI18n() // 国际化
|
||||
|
||||
// 列表和弹窗状态
|
||||
const channels = ref<ChannelItem[]>([]) // 自定义渠道列表
|
||||
const loading = ref(false) // 列表加载状态
|
||||
const submitting = ref(false) // 表单提交状态
|
||||
const showModal = ref(false) // 新增/改名弹窗显示状态
|
||||
const editingId = ref<number | null>(null) // 当前改名的渠道 ID(null 表示新增)
|
||||
const form = ref<{ name: string }>({ name: '' }) // 表单数据
|
||||
|
||||
/** 创建表格列配置 */
|
||||
function createColumns(): DataTableColumns<ChannelItem> {
|
||||
return [
|
||||
{ title: t('channel.name'), key: 'name' },
|
||||
{
|
||||
title: t('channel.usage'), key: 'transactionCount', width: 160,
|
||||
render(row) {
|
||||
return row.transactionCount > 0
|
||||
? h(NTag, { type: 'info', size: 'small' }, { default: () => t('channel.usageCount', { count: row.transactionCount }) })
|
||||
: h('span', { style: { color: '#999' } }, t('channel.unused'))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('common.operation'), key: 'actions', width: 180,
|
||||
render(row) {
|
||||
return h(NSpace, null, {
|
||||
default: () => [
|
||||
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }),
|
||||
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row) }, { default: () => t('common.delete') }),
|
||||
],
|
||||
})
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const columns = computed(() => createColumns()) // 表格列配置(随语言切换响应式更新)
|
||||
|
||||
/** 加载自定义渠道列表 */
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await getChannelList()
|
||||
channels.value = res.data || []
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('common.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开新增渠道弹窗,重置表单 */
|
||||
function handleAdd() {
|
||||
editingId.value = null
|
||||
form.value = { name: '' }
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
/** 打开改名弹窗,填充表单数据 */
|
||||
function handleEdit(row: ChannelItem) {
|
||||
editingId.value = row.id
|
||||
form.value = { name: row.name }
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
/** 提交表单:校验 -> 新增或改名 -> 刷新列表 */
|
||||
async function handleSubmit() {
|
||||
const name = form.value.name.trim()
|
||||
if (!name) {
|
||||
message.warning(t('channel.nameRequired'))
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await renameChannel(editingId.value, name)
|
||||
message.success(t('common.updateSuccess'))
|
||||
} else {
|
||||
await createChannel(name)
|
||||
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(row: ChannelItem) {
|
||||
dialog.warning({
|
||||
title: t('common.confirmDelete'),
|
||||
content: t('channel.deleteConfirm', { name: row.name }),
|
||||
positiveText: t('common.delete'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await deleteChannel(row.id)
|
||||
message.success(t('common.deleteSuccess'))
|
||||
await fetchData()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('common.deleteFailed'))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 返回收支记录页 */
|
||||
function goBack() {
|
||||
router.push('/transactions')
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 顶部:返回、标题和新增按钮 -->
|
||||
<NSpace justify="space-between" style="margin-bottom: 16px;">
|
||||
<NSpace align="center" :size="8">
|
||||
<NButton quaternary size="small" @click="goBack">
|
||||
<template #icon>
|
||||
<NIcon :component="ArrowBackOutline" />
|
||||
</template>
|
||||
</NButton>
|
||||
<h2 style="margin: 0;">{{ t('channel.title') }}</h2>
|
||||
</NSpace>
|
||||
<NButton type="primary" @click="handleAdd">{{ t('channel.add') }}</NButton>
|
||||
</NSpace>
|
||||
|
||||
<NAlert type="info" :show-icon="true" style="margin-bottom: 16px;">{{ t('channel.hint') }}</NAlert>
|
||||
|
||||
<!-- 自定义渠道数据表格 -->
|
||||
<NDataTable :columns="columns" :data="channels" :loading="loading" :bordered="false" />
|
||||
|
||||
<!-- 新增/改名弹窗 -->
|
||||
<NModal v-model:show="showModal" preset="card" :title="editingId ? t('channel.editTitle') : t('channel.addTitle')" style="width: 450px;">
|
||||
<NForm>
|
||||
<NFormItem :label="t('channel.name')">
|
||||
<NInput v-model:value="form.name" :placeholder="t('channel.namePlaceholder')" />
|
||||
</NFormItem>
|
||||
<NButton type="primary" block :loading="submitting" @click="handleSubmit">{{ t('common.confirm') }}</NButton>
|
||||
</NForm>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -515,7 +515,7 @@ async function fetchCategories() {
|
||||
async function loadChannels() {
|
||||
try {
|
||||
const res: any = await getChannels()
|
||||
channelCodes.value = res.data || []
|
||||
channelCodes.value = (res.data || []).map((item: any) => item.code)
|
||||
} catch (e) {
|
||||
// 静默处理,交易渠道下拉保持为空
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ const billTypeLabelKeys: Record<string, string> = {
|
||||
ALIPAY: 'import.billTypeALIPAY',
|
||||
WECHAT: 'import.billTypeWECHAT',
|
||||
MEITUAN: 'import.billTypeMEITUAN',
|
||||
TAOBAO: 'import.billTypeTAOBAO',
|
||||
JD: 'import.billTypeJD',
|
||||
CMB: 'import.billTypeCMB',
|
||||
BOCOM: 'import.billTypeBOCOM',
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { MenuOption } from 'naive-ui'
|
||||
import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, OptionsOutline, MailOutline, DocumentTextOutline, SettingsOutline, LogOutOutline, ChevronDownOutline, WalletOutline } from '@vicons/ionicons5'
|
||||
import { useUserStore } from '../store/user'
|
||||
import LocaleSwitch from '../components/LocaleSwitch.vue'
|
||||
import { getChannels } from '../api/transaction'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -179,11 +180,22 @@ watch(() => route.path, (to, from) => {
|
||||
nextTick(() => restoreScroll(to))
|
||||
})
|
||||
|
||||
/** 预加载交易渠道列表,注册自定义渠道名称供全局展示解析 */
|
||||
async function loadChannels() {
|
||||
try {
|
||||
await getChannels()
|
||||
} catch (e) {
|
||||
// 静默处理,展示时回退为渠道编码
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getScrollEl()?.addEventListener('scroll', handleScroll, { passive: true })
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
// 刷新或直接进入页面时恢复当前位置
|
||||
restoreScroll(route.path)
|
||||
// 预加载渠道列表,保证自定义渠道名称在各页面可正确展示
|
||||
loadChannels()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@@ -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