diff --git a/README.md b/README.md index 5bb689b..f25fbcf 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,16 @@ - 分类、标签的增删改查,新注册用户自动获得一套默认数据 - 分类图标支持 AI 推荐 Emoji,标签颜色支持 AI 推荐十六进制色值 +### 交易渠道 +- 内置渠道:支付宝、微信、美团、淘宝、京东、招商银行、交通银行、现金 +- 支持自定义渠道的新建、改名与删除(有账单使用时不可删除,改名不影响历史账单) + ### 统计与图表 - 月度收支统计、分类占比、收支趋势、分组统计与概览汇总 - 基于 ECharts 的可视化图表展示 ### 账单导入 -- 支持支付宝(CSV)、微信(xlsx)、美团、交通银行、招商银行(PDF)账单解析 +- 支持支付宝(CSV)、微信(xlsx)、美团、淘宝(xlsx)、京东(CSV)、交通银行、招商银行(PDF)账单解析 - 支持 zip 压缩包(含 ZipCrypto / AES 加密压缩包)上传 - 「解析预览 → 确认导入」两步流程,按交易号去重 @@ -31,6 +35,7 @@ ### AI 能力(DeepSeek) - 交易记录分类与标签的批量 / 单条智能建议,支持一键采纳 - 自然语言查询解析与自然语言记账解析 +- 图片记账:上传账单 / 支付截图,由视觉模型识别并生成多条记录,逐条预览勾选后批量导入 - AI 生成分类、标签,以及 Emoji / 颜色推荐 - AI 调用用量统计与明细查看 @@ -138,7 +143,7 @@ finance-app/ │ ├── entity/ # 实体类 │ ├── enums/ # 枚举 │ ├── exporter/ # 导出处理 -│ ├── importer/ # 账单解析器(支付宝/微信/美团/银行) +│ ├── importer/ # 账单解析器(支付宝/微信/美团/淘宝/京东/银行) │ ├── interceptor/ # 拦截器(JWT、访问日志) │ ├── mapper/ # 数据访问层 │ ├── notification/ # 通知渠道发送器 diff --git a/frontend/src/api/ai.ts b/frontend/src/api/ai.ts index 9b2fee8..726124c 100644 --- a/frontend/src/api/ai.ts +++ b/frontend/src/api/ai.ts @@ -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 diff --git a/frontend/src/api/channel.ts b/frontend/src/api/channel.ts new file mode 100644 index 0000000..9b510c6 --- /dev/null +++ b/frontend/src/api/channel.ts @@ -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}`) +} diff --git a/frontend/src/api/transaction.ts b/frontend/src/api/transaction.ts index a9907d8..1962c83 100644 --- a/frontend/src/api/transaction.ts +++ b/frontend/src/api/transaction.ts @@ -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 diff --git a/frontend/src/components/AiConfigPanel.vue b/frontend/src/components/AiConfigPanel.vue index feadb86..7bd0165 100644 --- a/frontend/src/components/AiConfigPanel.vue +++ b/frontend/src/components/AiConfigPanel.vue @@ -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) + + + + + diff --git a/frontend/src/locales/messages.ts b/frontend/src/locales/messages.ts index cfb9594..cf22ef9 100644 --- a/frontend/src/locales/messages.ts +++ b/frontend/src/locales/messages.ts @@ -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: '記録' }, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 62f5076..7a219f3 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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', // 跳转到上次访问的标签页,无记录时默认邮箱配置 diff --git a/frontend/src/utils/channel.ts b/frontend/src/utils/channel.ts index 8230297..f621ae9 100644 --- a/frontend/src/utils/channel.ts +++ b/frontend/src/utils/channel.ts @@ -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() + +/** + * 注册渠道选项:仅记录用户自定义渠道(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 diff --git a/frontend/src/utils/sse.ts b/frontend/src/utils/sse.ts index 28b5209..01eaf5d 100644 --- a/frontend/src/utils/sse.ts +++ b/frontend/src/utils/sse.ts @@ -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) => { diff --git a/frontend/src/views/BillImportPanel.vue b/frontend/src/views/BillImportPanel.vue index f4af85f..ca13baa 100644 --- a/frontend/src/views/BillImportPanel.vue +++ b/frontend/src/views/BillImportPanel.vue @@ -32,6 +32,8 @@ const billTypeLabelKeys: Record = { 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 = { ALIPAY: 'import.exportHintALIPAY', WECHAT: 'import.exportHintWECHAT', MEITUAN: 'import.exportHintMEITUAN', + TAOBAO: 'import.exportHintTAOBAO', + JD: 'import.exportHintJD', CMB: 'import.exportHintCMB', BOCOM: 'import.exportHintBOCOM', } diff --git a/frontend/src/views/ChannelManage.vue b/frontend/src/views/ChannelManage.vue new file mode 100644 index 0000000..ff576ef --- /dev/null +++ b/frontend/src/views/ChannelManage.vue @@ -0,0 +1,162 @@ + + + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index c09093f..c236278 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -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) { // 静默处理,交易渠道下拉保持为空 } diff --git a/frontend/src/views/EmailBillManage.vue b/frontend/src/views/EmailBillManage.vue index 52023bd..ef74c98 100644 --- a/frontend/src/views/EmailBillManage.vue +++ b/frontend/src/views/EmailBillManage.vue @@ -54,6 +54,8 @@ const billTypeLabelKeys: Record = { ALIPAY: 'import.billTypeALIPAY', WECHAT: 'import.billTypeWECHAT', MEITUAN: 'import.billTypeMEITUAN', + TAOBAO: 'import.billTypeTAOBAO', + JD: 'import.billTypeJD', CMB: 'import.billTypeCMB', BOCOM: 'import.billTypeBOCOM', } diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue index b29d639..c4c3bd0 100644 --- a/frontend/src/views/Layout.vue +++ b/frontend/src/views/Layout.vue @@ -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(() => { diff --git a/frontend/src/views/TransactionList.vue b/frontend/src/views/TransactionList.vue index 2c73838..32b34c0 100644 --- a/frontend/src/views/TransactionList.vue +++ b/frontend/src/views/TransactionList.vue @@ -1,23 +1,27 @@ @@ -1534,6 +2067,7 @@ onMounted(() => { {{ t('transaction.batchDelete') }} {{ t('transaction.importBill') }} + {{ t('channel.manageEntry') }} {{ t('transaction.exportExcel') }}