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

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