create transactions from AI receipt image recognition results

This commit is contained in:
MaysWind
2025-09-21 04:00:56 +08:00
parent 00f1d0418f
commit 5d88287ae2
50 changed files with 2356 additions and 22 deletions
@@ -0,0 +1,181 @@
<template>
<f7-sheet swipe-to-close swipe-handler=".swipe-handler" style="height:auto"
:opened="show" @sheet:open="onSheetOpen" @sheet:closed="onSheetClosed">
<f7-toolbar>
<div class="swipe-handler"></div>
<div class="left">
<f7-link :class="{ 'disabled': loading || recognizing }" :text="tt('Choose from Library')" @click="showOpenImage"></f7-link>
</div>
<div class="right">
<f7-link :class="{ 'disabled': loading || recognizing }" :text="tt('Take Photo')" @click="showCamera"></f7-link>
</div>
</f7-toolbar>
<f7-page-content class="margin-top no-padding-top">
<div class="padding-horizontal padding-bottom">
<div class="image-container display-flex justify-content-center width-100 margin-bottom" style="height: 240px">
<img height="240px" :src="imageSrc" v-if="imageSrc" />
<div class="image-container-background display-flex justify-content-center align-items-center" v-if="!imageSrc">
<span>{{ tt('Please select a receipt or transaction image first') }}</span>
</div>
</div>
<f7-button large fill color="primary"
:class="{ 'disabled': loading || recognizing || !imageFile }"
:text="tt('Recognize')"
@click="confirm">
</f7-button>
<div class="margin-top text-align-center">
<f7-link :class="{ 'disabled': loading || recognizing }" @click="cancel" :text="tt('Cancel')"></f7-link>
</div>
</div>
</f7-page-content>
<input ref="imageInput" type="file" style="display: none" :accept="SUPPORTED_IMAGE_EXTENSIONS" @change="openImage($event)" />
<input ref="cameraInput" type="file" style="display: none" :accept="SUPPORTED_IMAGE_EXTENSIONS" capture="environment" @change="openImage($event)" />
</f7-sheet>
</template>
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
import { useI18nUIComponents, showLoading, hideLoading } from '@/lib/ui/mobile.ts';
import { useTransactionsStore } from '@/stores/transaction.ts';
import { KnownFileType } from '@/core/file.ts';
import { SUPPORTED_IMAGE_EXTENSIONS } from '@/consts/file.ts';
import type { RecognizedReceiptImageResponse } from '@/models/large_language_model.ts';
import { compressJpgImage } from '@/lib/ui/common.ts';
import logger from '@/lib/logger.ts';
defineProps<{
show: boolean;
}>();
const emit = defineEmits<{
(e: 'update:show', value: boolean): void;
(e: 'recognition:change', value: RecognizedReceiptImageResponse): void;
}>();
const { tt } = useI18n();
const { showToast } = useI18nUIComponents();
const transactionsStore = useTransactionsStore();
const imageInput = useTemplateRef<HTMLInputElement>('imageInput');
const cameraInput = useTemplateRef<HTMLInputElement>('cameraInput');
const loading = ref<boolean>(false);
const recognizing = ref<boolean>(false);
const imageFile = ref<File | null>(null);
const imageSrc = ref<string | undefined>(undefined);
function loadImage(file: File): void {
compressJpgImage(file, 1280, 1280, 0.8).then(blob => {
imageFile.value = KnownFileType.JPG.createFileFromBlob(blob, "image");
imageSrc.value = URL.createObjectURL(blob);
}).catch(error => {
imageFile.value = null;
imageSrc.value = undefined;
logger.error('failed to compress image', error);
showToast('Unable to load image');
});
}
function showOpenImage(): void {
if (loading.value || recognizing.value) {
return;
}
imageInput.value?.click();
}
function showCamera(): void {
if (loading.value || recognizing.value) {
return;
}
cameraInput.value?.click();
}
function openImage(event: Event): void {
if (!event || !event.target) {
return;
}
const el = event.target as HTMLInputElement;
if (!el.files || !el.files.length || !el.files[0]) {
return;
}
const image = el.files[0] as File;
el.value = '';
loadImage(image);
}
function confirm(): void {
if (loading.value || recognizing.value || !imageFile.value) {
return;
}
recognizing.value = true;
showLoading(() => recognizing.value);
transactionsStore.recognizeReceiptImage({
imageFile: imageFile.value
}).then(response => {
recognizing.value = false;
hideLoading();
emit('update:show', false);
emit('recognition:change', response);
}).catch(error => {
recognizing.value = false;
hideLoading();
if (!error.processed) {
showToast(error.message || error);
}
});
}
function cancel(): void {
close();
}
function close(): void {
emit('update:show', false);
loading.value = false;
recognizing.value = false;
imageFile.value = null;
imageSrc.value = undefined;
}
function onSheetOpen(): void {
loading.value = false;
recognizing.value = false;
imageFile.value = null;
imageSrc.value = undefined;
}
function onSheetClosed(): void {
close();
}
</script>
<style>
.image-container {
border: 1px solid var(--f7-page-master-border-color);
}
.image-container-background {
width: 100%;
height: 100%;
background-color: var(--f7-page-bg-color);
}
</style>
+1
View File
@@ -7,6 +7,7 @@ export const DEFAULT_API_TIMEOUT: number = 10000; // 10s
export const DEFAULT_UPLOAD_API_TIMEOUT: number = 30000; // 30s
export const DEFAULT_EXPORT_API_TIMEOUT: number = 180000; // 180s
export const DEFAULT_IMPORT_API_TIMEOUT: number = 1800000; // 1800s
export const DEFAULT_LLM_API_TIMEOUT: number = 600000; // 600s
export const GOOGLE_MAP_JAVASCRIPT_URL: string = 'https://maps.googleapis.com/maps/api/js';
export const BAIDU_MAP_JAVASCRIPT_URL: string = 'https://api.map.baidu.com/api?v=3.0';
+7
View File
@@ -6,6 +6,7 @@ export class KnownFileType {
public static readonly TSV = new KnownFileType('tsv', 'text/tab-separated-values');
public static readonly MARKDOWN = new KnownFileType('md', 'text/markdown');
public static readonly JS = new KnownFileType('js', 'application/javascript');
public static readonly JPG = new KnownFileType('jpg', 'image/jpeg');
public readonly extension: string;
public readonly contentType: string;
@@ -37,6 +38,12 @@ export class KnownFileType {
});
}
public createFileFromBlob(blob: Blob, fileName: string): File {
return new File([blob], this.formatFileName(fileName), {
type: this.contentType,
});
}
public static parse(extension: string): KnownFileType | undefined {
return KnownFileType.allInstancesByExtension[extension];
}
+4
View File
@@ -35,6 +35,10 @@ export function isMCPServerEnabled(): boolean {
return getServerSetting('mcp') === 1;
}
export function isTransactionFromAIImageRecognitionEnabled(): boolean {
return getServerSetting('llmt') === 1;
}
export function getLoginPageTips(): Record<string, string>{
return getServerSetting('lpt') as Record<string, string>;
}
+11
View File
@@ -21,6 +21,7 @@ import {
DEFAULT_UPLOAD_API_TIMEOUT,
DEFAULT_EXPORT_API_TIMEOUT,
DEFAULT_IMPORT_API_TIMEOUT,
DEFAULT_LLM_API_TIMEOUT,
GOOGLE_MAP_JAVASCRIPT_URL,
BAIDU_MAP_JAVASCRIPT_URL,
AMAP_JAVASCRIPT_URL
@@ -134,6 +135,9 @@ import type {
import type {
UserApplicationCloudSettingsUpdateRequest
} from '@/models/user_app_cloud_setting.ts';
import type {
RecognizedReceiptImageResponse
} from '@/models/large_language_model.ts';
import {
getCurrentToken,
@@ -635,6 +639,13 @@ export default {
deleteTransactionTemplate: (req: TransactionTemplateDeleteRequest): ApiResponsePromise<boolean> => {
return axios.post<ApiResponse<boolean>>('v1/transaction/templates/delete.json', req);
},
recognizeReceiptImage: ({ imageFile }: { imageFile: File }): ApiResponsePromise<RecognizedReceiptImageResponse> => {
return axios.postForm<ApiResponse<RecognizedReceiptImageResponse>>('v1/llm/transactions/recognize_receipt_image.json', {
image: imageFile
}, {
timeout: DEFAULT_LLM_API_TIMEOUT
});
},
getLatestExchangeRates: (param: { ignoreError?: boolean }): ApiResponsePromise<LatestExchangeRateResponse> => {
return axios.get<ApiResponse<LatestExchangeRateResponse>>('v1/exchange_rates/latest.json', {
ignoreError: !!param.ignoreError,
+59
View File
@@ -3,6 +3,7 @@ import Clipboard from 'clipboard';
import { ThemeType } from '@/core/theme.ts';
import { type AmountColor, PresetAmountColor } from '@/core/color.ts';
import { KnownFileType } from '@/core/file.ts';
import logger from '../logger.ts';
@@ -134,6 +135,64 @@ export function startDownloadFile(fileName: string, fileData: Blob): void {
dataLink.click();
}
export function compressJpgImage(file: File, maxWidth: number, maxHeight: number, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
let width = img.width;
let height = img.height;
if (width > maxWidth || height > maxHeight) {
const scale = Math.min(maxWidth / width, maxHeight / height);
width = Math.floor(width * scale);
height = Math.floor(height * scale);
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('failed to get canvas context'));
return;
}
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('failed to compress image'));
}
}, KnownFileType.JPG.contentType, quality);
};
img.onerror = (error) => {
reject(error);
};
if (event.target && event.target.result) {
img.src = event.target.result as string;
} else {
reject(new Error('failed to read file'));
}
};
reader.onerror = (error) => {
reject(error);
};
reader.readAsDataURL(file);
});
}
export function clearBrowserCaches(): Promise<void> {
if (!window.caches) {
logger.error('caches API is not supported in this browser');
+13
View File
@@ -1224,6 +1224,10 @@
"cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency",
"cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency",
"mcp server is not enabled": "MCP Server is not enabled",
"llm provider is not enabled": "Large Language Model provider is not enabled",
"no image for AI recognition": "There is no image for AI recognition",
"image for AI recognition is empty": "Image for AI recognition file is empty",
"exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size",
"query items cannot be blank": "Abfrageelemente dürfen nicht leer sein",
"query items too much": "Zu viele Abfrageelemente",
"query items have invalid item": "Ungültiges Element in Abfrageelementen",
@@ -1389,6 +1393,7 @@
"Refresh": "Aktualisieren",
"Clear": "Löschen",
"Generate": "Generate",
"Recognize": "Recognize",
"None": "Keine",
"Unspecified": "Nicht angegeben",
"Not set": "Nicht festgelegt",
@@ -1719,6 +1724,14 @@
"Duplicate (With Time)": "Duplicate (With Time)",
"Duplicate (With Geographic Location)": "Duplicate (With Geographic Location)",
"Duplicate (With Time and Geographic Location)": "Duplicate (With Time and Geographic Location)",
"AI Image Recognition": "AI Image Recognition",
"Choose from Library": "Choose from Library",
"Take Photo": "Take Photo",
"Unable to load image": "Unable to load image",
"Unable to recognize image": "Unable to recognize image",
"Drag and drop a receipt or transaction image here, or click to select one": "Drag and drop a receipt or transaction image here, or click to select one",
"Release to load image": "Release to load image",
"Please select a receipt or transaction image first": "Please select a receipt or transaction image first",
"Category": "Kategorie",
"Secondary Category": "Secondary Category",
"Expense Category": "Expense Category",
+13
View File
@@ -1224,6 +1224,10 @@
"cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency",
"cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency",
"mcp server is not enabled": "MCP Server is not enabled",
"llm provider is not enabled": "Large Language Model provider is not enabled",
"no image for AI recognition": "There is no image for AI recognition",
"image for AI recognition is empty": "Image for AI recognition file is empty",
"exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size",
"query items cannot be blank": "There are no query items",
"query items too much": "There are too many query items",
"query items have invalid item": "There is invalid item in query items",
@@ -1389,6 +1393,7 @@
"Refresh": "Refresh",
"Clear": "Clear",
"Generate": "Generate",
"Recognize": "Recognize",
"None": "None",
"Unspecified": "Unspecified",
"Not set": "Not set",
@@ -1719,6 +1724,14 @@
"Duplicate (With Time)": "Duplicate (With Time)",
"Duplicate (With Geographic Location)": "Duplicate (With Geographic Location)",
"Duplicate (With Time and Geographic Location)": "Duplicate (With Time and Geographic Location)",
"AI Image Recognition": "AI Image Recognition",
"Choose from Library": "Choose from Library",
"Take Photo": "Take Photo",
"Unable to load image": "Unable to load image",
"Unable to recognize image": "Unable to recognize image",
"Drag and drop a receipt or transaction image here, or click to select one": "Drag and drop a receipt or transaction image here, or click to select one",
"Release to load image": "Release to load image",
"Please select a receipt or transaction image first": "Please select a receipt or transaction image first",
"Category": "Category",
"Secondary Category": "Secondary Category",
"Expense Category": "Expense Category",
+13
View File
@@ -1224,6 +1224,10 @@
"cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency",
"cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency",
"mcp server is not enabled": "MCP Server is not enabled",
"llm provider is not enabled": "Large Language Model provider is not enabled",
"no image for AI recognition": "There is no image for AI recognition",
"image for AI recognition is empty": "Image for AI recognition file is empty",
"exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size",
"query items cannot be blank": "--",
"query items too much": "--",
"query items have invalid item": "Hay un elemento no válido en los elementos de consulta",
@@ -1389,6 +1393,7 @@
"Refresh": "Refrescar",
"Clear": "Claro",
"Generate": "Generate",
"Recognize": "Recognize",
"None": "Ninguno",
"Unspecified": "No especificado",
"Not set": "No establecido",
@@ -1719,6 +1724,14 @@
"Duplicate (With Time)": "Duplicate (With Time)",
"Duplicate (With Geographic Location)": "Duplicate (With Geographic Location)",
"Duplicate (With Time and Geographic Location)": "Duplicate (With Time and Geographic Location)",
"AI Image Recognition": "AI Image Recognition",
"Choose from Library": "Choose from Library",
"Take Photo": "Take Photo",
"Unable to load image": "Unable to load image",
"Unable to recognize image": "Unable to recognize image",
"Drag and drop a receipt or transaction image here, or click to select one": "Drag and drop a receipt or transaction image here, or click to select one",
"Release to load image": "Release to load image",
"Please select a receipt or transaction image first": "Please select a receipt or transaction image first",
"Category": "Categoría",
"Secondary Category": "Secondary Category",
"Expense Category": "Expense Category",
+13
View File
@@ -1224,6 +1224,10 @@
"cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency",
"cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency",
"mcp server is not enabled": "MCP Server is not enabled",
"llm provider is not enabled": "Large Language Model provider is not enabled",
"no image for AI recognition": "There is no image for AI recognition",
"image for AI recognition is empty": "Image for AI recognition file is empty",
"exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size",
"query items cannot be blank": "Non ci sono elementi di query",
"query items too much": "Ci sono troppi elementi di query",
"query items have invalid item": "C'è un elemento non valido negli elementi di query",
@@ -1389,6 +1393,7 @@
"Refresh": "Aggiorna",
"Clear": "Pulisci",
"Generate": "Generate",
"Recognize": "Recognize",
"None": "Nessuno",
"Unspecified": "Non specificato",
"Not set": "Non impostato",
@@ -1719,6 +1724,14 @@
"Duplicate (With Time)": "Duplica (con ora)",
"Duplicate (With Geographic Location)": "Duplica (con posizione geografica)",
"Duplicate (With Time and Geographic Location)": "Duplica (con ora e posizione geografica)",
"AI Image Recognition": "AI Image Recognition",
"Choose from Library": "Choose from Library",
"Take Photo": "Take Photo",
"Unable to load image": "Unable to load image",
"Unable to recognize image": "Unable to recognize image",
"Drag and drop a receipt or transaction image here, or click to select one": "Drag and drop a receipt or transaction image here, or click to select one",
"Release to load image": "Release to load image",
"Please select a receipt or transaction image first": "Please select a receipt or transaction image first",
"Category": "Categoria",
"Secondary Category": "Categoria secondaria",
"Expense Category": "Expense Category",
+13
View File
@@ -1224,6 +1224,10 @@
"cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency",
"cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency",
"mcp server is not enabled": "MCP Server is not enabled",
"llm provider is not enabled": "Large Language Model provider is not enabled",
"no image for AI recognition": "There is no image for AI recognition",
"image for AI recognition is empty": "Image for AI recognition file is empty",
"exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size",
"query items cannot be blank": "クエリ項目がありません",
"query items too much": "クエリ項目が多すぎます",
"query items have invalid item": "クエリ項目に無効な項目があります",
@@ -1389,6 +1393,7 @@
"Refresh": "リフレッシュ",
"Clear": "消去",
"Generate": "Generate",
"Recognize": "Recognize",
"None": "なし",
"Unspecified": "不特定",
"Not set": "セットしていない",
@@ -1719,6 +1724,14 @@
"Duplicate (With Time)": "複製(時間含む)",
"Duplicate (With Geographic Location)": "複製(地理座標を含む)",
"Duplicate (With Time and Geographic Location)": "複製(時間と地理座標を含む)",
"AI Image Recognition": "AI Image Recognition",
"Choose from Library": "Choose from Library",
"Take Photo": "Take Photo",
"Unable to load image": "Unable to load image",
"Unable to recognize image": "Unable to recognize image",
"Drag and drop a receipt or transaction image here, or click to select one": "Drag and drop a receipt or transaction image here, or click to select one",
"Release to load image": "Release to load image",
"Please select a receipt or transaction image first": "Please select a receipt or transaction image first",
"Category": "カテゴリ",
"Secondary Category": "二次カテゴリ",
"Expense Category": "Expense Category",
+13
View File
@@ -1224,6 +1224,10 @@
"cannot update exchange rate data for base currency": "Wisselkoersgegevens voor basisvaluta kunnen niet worden bijgewerkt",
"cannot delete exchange rate data for base currency": "Wisselkoersgegevens voor basisvaluta kunnen niet worden verwijderd",
"mcp server is not enabled": "MCP-server is niet ingeschakeld",
"llm provider is not enabled": "Large Language Model provider is not enabled",
"no image for AI recognition": "There is no image for AI recognition",
"image for AI recognition is empty": "Image for AI recognition file is empty",
"exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size",
"query items cannot be blank": "Geen zoekitems opgegeven",
"query items too much": "Te veel zoekitems",
"query items have invalid item": "Ongeldig item in zoekitems",
@@ -1389,6 +1393,7 @@
"Refresh": "Vernieuwen",
"Clear": "Wissen",
"Generate": "Genereren",
"Recognize": "Recognize",
"None": "Geen",
"Unspecified": "Niet gespecificeerd",
"Not set": "Niet ingesteld",
@@ -1719,6 +1724,14 @@
"Duplicate (With Time)": "Dupliceren (met tijd)",
"Duplicate (With Geographic Location)": "Dupliceren (met geografische locatie)",
"Duplicate (With Time and Geographic Location)": "Dupliceren (met tijd en locatie)",
"AI Image Recognition": "AI Image Recognition",
"Choose from Library": "Choose from Library",
"Take Photo": "Take Photo",
"Unable to load image": "Unable to load image",
"Unable to recognize image": "Unable to recognize image",
"Drag and drop a receipt or transaction image here, or click to select one": "Drag and drop a receipt or transaction image here, or click to select one",
"Release to load image": "Release to load image",
"Please select a receipt or transaction image first": "Please select a receipt or transaction image first",
"Category": "Categorie",
"Secondary Category": "Secundaire categorie",
"Expense Category": "Uitgavecategorie",
+13
View File
@@ -1224,6 +1224,10 @@
"cannot update exchange rate data for base currency": "Não é possível atualizar dados de taxa de câmbio para a moeda base",
"cannot delete exchange rate data for base currency": "Não é possível excluir dados de taxa de câmbio para a moeda base",
"mcp server is not enabled": "MCP Server is not enabled",
"llm provider is not enabled": "Large Language Model provider is not enabled",
"no image for AI recognition": "There is no image for AI recognition",
"image for AI recognition is empty": "Image for AI recognition file is empty",
"exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size",
"query items cannot be blank": "Não há itens de consulta",
"query items too much": "Há muitos itens de consulta",
"query items have invalid item": "Há item inválido nos itens de consulta",
@@ -1389,6 +1393,7 @@
"Refresh": "Atualizar",
"Clear": "Limpar",
"Generate": "Generate",
"Recognize": "Recognize",
"None": "Nenhum",
"Unspecified": "Não especificado",
"Not set": "Não definido",
@@ -1719,6 +1724,14 @@
"Duplicate (With Time)": "Duplicar (Com Tempo)",
"Duplicate (With Geographic Location)": "Duplicar (Com Localização Geográfica)",
"Duplicate (With Time and Geographic Location)": "Duplicar (Com Tempo e Localização Geográfica)",
"AI Image Recognition": "AI Image Recognition",
"Choose from Library": "Choose from Library",
"Take Photo": "Take Photo",
"Unable to load image": "Unable to load image",
"Unable to recognize image": "Unable to recognize image",
"Drag and drop a receipt or transaction image here, or click to select one": "Drag and drop a receipt or transaction image here, or click to select one",
"Release to load image": "Release to load image",
"Please select a receipt or transaction image first": "Please select a receipt or transaction image first",
"Category": "Categoria",
"Secondary Category": "Categoria Secundária",
"Expense Category": "Expense Category",
+13
View File
@@ -1224,6 +1224,10 @@
"cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency",
"cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency",
"mcp server is not enabled": "MCP Server is not enabled",
"llm provider is not enabled": "Large Language Model provider is not enabled",
"no image for AI recognition": "There is no image for AI recognition",
"image for AI recognition is empty": "Image for AI recognition file is empty",
"exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size",
"query items cannot be blank": "Нет элементов запроса",
"query items too much": "Слишком много элементов запроса",
"query items have invalid item": "В элементах запроса присутствует недопустимый элемент",
@@ -1389,6 +1393,7 @@
"Refresh": "Обновить",
"Clear": "Очистить",
"Generate": "Generate",
"Recognize": "Recognize",
"None": "Нет",
"Unspecified": "Не указано",
"Not set": "Не установлено",
@@ -1719,6 +1724,14 @@
"Duplicate (With Time)": "Duplicate (With Time)",
"Duplicate (With Geographic Location)": "Duplicate (With Geographic Location)",
"Duplicate (With Time and Geographic Location)": "Duplicate (With Time and Geographic Location)",
"AI Image Recognition": "AI Image Recognition",
"Choose from Library": "Choose from Library",
"Take Photo": "Take Photo",
"Unable to load image": "Unable to load image",
"Unable to recognize image": "Unable to recognize image",
"Drag and drop a receipt or transaction image here, or click to select one": "Drag and drop a receipt or transaction image here, or click to select one",
"Release to load image": "Release to load image",
"Please select a receipt or transaction image first": "Please select a receipt or transaction image first",
"Category": "Категория",
"Secondary Category": "Secondary Category",
"Expense Category": "Expense Category",
+13
View File
@@ -1224,6 +1224,10 @@
"cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency",
"cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency",
"mcp server is not enabled": "MCP Server is not enabled",
"llm provider is not enabled": "Large Language Model provider is not enabled",
"no image for AI recognition": "There is no image for AI recognition",
"image for AI recognition is empty": "Image for AI recognition file is empty",
"exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size",
"query items cannot be blank": "Елементи запиту не можуть бути порожніми",
"query items too much": "Занадто багато елементів запиту",
"query items have invalid item": "Запит містить недійсний елемент",
@@ -1389,6 +1393,7 @@
"Refresh": "Оновити",
"Clear": "Очистити",
"Generate": "Generate",
"Recognize": "Recognize",
"None": "Немає",
"Unspecified": "Не вказано",
"Not set": "Не встановлено",
@@ -1719,6 +1724,14 @@
"Duplicate (With Time)": "Дублювати (з часом)",
"Duplicate (With Geographic Location)": "Дублювати (з геолокацією)",
"Duplicate (With Time and Geographic Location)": "Дублювати (з часом і геолокацією)",
"AI Image Recognition": "AI Image Recognition",
"Choose from Library": "Choose from Library",
"Take Photo": "Take Photo",
"Unable to load image": "Unable to load image",
"Unable to recognize image": "Unable to recognize image",
"Drag and drop a receipt or transaction image here, or click to select one": "Drag and drop a receipt or transaction image here, or click to select one",
"Release to load image": "Release to load image",
"Please select a receipt or transaction image first": "Please select a receipt or transaction image first",
"Category": "Категорія",
"Secondary Category": "Вторинна категорія",
"Expense Category": "Expense Category",
+13
View File
@@ -1224,6 +1224,10 @@
"cannot update exchange rate data for base currency": "Cannot update exchange rate data for base currency",
"cannot delete exchange rate data for base currency": "Cannot delete exchange rate data for base currency",
"mcp server is not enabled": "MCP Server is not enabled",
"llm provider is not enabled": "Large Language Model provider is not enabled",
"no image for AI recognition": "There is no image for AI recognition",
"image for AI recognition is empty": "Image for AI recognition file is empty",
"exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size",
"query items cannot be blank": "Không có mục truy vấn",
"query items too much": "Có quá nhiều mục truy vấn",
"query items have invalid item": "Có mục không hợp lệ trong các mục truy vấn",
@@ -1389,6 +1393,7 @@
"Refresh": "Làm mới",
"Clear": "Xóa",
"Generate": "Generate",
"Recognize": "Recognize",
"None": "Không có",
"Unspecified": "Không xác định",
"Not set": "Not set",
@@ -1719,6 +1724,14 @@
"Duplicate (With Time)": "Duplicate (With Time)",
"Duplicate (With Geographic Location)": "Duplicate (With Geographic Location)",
"Duplicate (With Time and Geographic Location)": "Duplicate (With Time and Geographic Location)",
"AI Image Recognition": "AI Image Recognition",
"Choose from Library": "Choose from Library",
"Take Photo": "Take Photo",
"Unable to load image": "Unable to load image",
"Unable to recognize image": "Unable to recognize image",
"Drag and drop a receipt or transaction image here, or click to select one": "Drag and drop a receipt or transaction image here, or click to select one",
"Release to load image": "Release to load image",
"Please select a receipt or transaction image first": "Please select a receipt or transaction image first",
"Category": "Danh mục",
"Secondary Category": "Secondary Category",
"Expense Category": "Expense Category",
+13
View File
@@ -1224,6 +1224,10 @@
"cannot update exchange rate data for base currency": "不能更新默认货币的汇率数据",
"cannot delete exchange rate data for base currency": "不能删除默认货币的汇率数据",
"mcp server is not enabled": "MCP 服务器没有启用",
"llm provider is not enabled": "大语言模型服务提供者没有启用",
"no image for AI recognition": "没有用于AI识别的图片",
"image for AI recognition is empty": "用于AI识别的图片为空",
"exceed the maximum size of image file for AI recognition": "用于AI识别的图片超出了允许的最大文件大小",
"query items cannot be blank": "请求项目不能为空",
"query items too much": "请求项目过多",
"query items have invalid item": "请求项目中有非法项目",
@@ -1389,6 +1393,7 @@
"Refresh": "刷新",
"Clear": "清除",
"Generate": "生成",
"Recognize": "识别",
"None": "无",
"Unspecified": "未指定",
"Not set": "未设置",
@@ -1719,6 +1724,14 @@
"Duplicate (With Time)": "复制 (含时间)",
"Duplicate (With Geographic Location)": "复制 (含地理位置)",
"Duplicate (With Time and Geographic Location)": "复制 (含时间和地理位置)",
"AI Image Recognition": "AI识图",
"Choose from Library": "从图库选择",
"Take Photo": "拍照",
"Unable to load image": "无法加载图片",
"Unable to recognize image": "无法识别图片",
"Drag and drop a receipt or transaction image here, or click to select one": "拖拽收据或交易图片到此处,或点击选择图片",
"Release to load image": "释放以加载图片",
"Please select a receipt or transaction image first": "请先选择收据或交易图片",
"Category": "分类",
"Secondary Category": "二级分类",
"Expense Category": "支出分类",
+13
View File
@@ -1224,6 +1224,10 @@
"cannot update exchange rate data for base currency": "不能更新基準貨幣的匯率資料",
"cannot delete exchange rate data for base currency": "不能刪除基準貨幣的匯率資料",
"mcp server is not enabled": "MCP 伺服器未啟用",
"llm provider is not enabled": "大型語言模型服務提供者未啟用",
"no image for AI recognition": "沒有用於AI識別的圖片檔案",
"image for AI recognition is empty": "用於AI識別的圖片檔案為空",
"exceed the maximum size of image file for AI recognition": "用於AI識別的圖片超出了允許的最大檔案大小",
"query items cannot be blank": "查詢項目不能為空",
"query items too much": "查詢項目過多",
"query items have invalid item": "查詢項目中有非法項目",
@@ -1389,6 +1393,7 @@
"Refresh": "重新載入",
"Clear": "清除",
"Generate": "產生",
"Recognize": "識別",
"None": "無",
"Unspecified": "未指定",
"Not set": "未設置",
@@ -1719,6 +1724,14 @@
"Duplicate (With Time)": "複製 (含時間)",
"Duplicate (With Geographic Location)": "複製 (含地理位置)",
"Duplicate (With Time and Geographic Location)": "複製 (含時間和地理位置)",
"AI Image Recognition": "AI識圖",
"Choose from Library": "從相簿選擇",
"Take Photo": "拍照",
"Unable to load image": "無法載入圖片",
"Unable to recognize image": "無法識別圖片",
"Drag and drop a receipt or transaction image here, or click to select one": "將收據或交易圖片拖放到此處,或點擊以選擇圖片",
"Release to load image": "放開以載入圖片",
"Please select a receipt or transaction image first": "請先選擇收據或交易圖片",
"Category": "分類",
"Secondary Category": "次分類",
"Expense Category": "支出分類",
+3 -1
View File
@@ -79,6 +79,7 @@ import MapSheet from '@/components/mobile/MapSheet.vue';
import TransactionTagSelectionSheet from '@/components/mobile/TransactionTagSelectionSheet.vue';
import ScheduleFrequencySheet from '@/components/mobile/ScheduleFrequencySheet.vue';
import AccountBalanceTrendsBarChart from '@/components/mobile/AccountBalanceTrendsBarChart.vue';
import AIImageRecognitionSheet from '@/components/mobile/AIImageRecognitionSheet.vue';
import TextareaAutoSize from '@/directives/mobile/textareaAutoSize.ts';
@@ -170,8 +171,9 @@ app.component('InformationSheet', InformationSheet);
app.component('NumberPadSheet', NumberPadSheet);
app.component('MapSheet', MapSheet);
app.component('TransactionTagSelectionSheet', TransactionTagSelectionSheet);
app.component('AccountBalanceTrendsBarChart', AccountBalanceTrendsBarChart);
app.component('ScheduleFrequencySheet', ScheduleFrequencySheet);
app.component('AccountBalanceTrendsBarChart', AccountBalanceTrendsBarChart);
app.component('AIImageRecognitionSheet', AIImageRecognitionSheet);
app.directive('TextareaAutoSize', TextareaAutoSize);
+11
View File
@@ -0,0 +1,11 @@
export interface RecognizedReceiptImageResponse {
readonly type: number;
readonly time?: number;
readonly categoryId?: string;
readonly sourceAccountId?: string;
readonly destinationAccountId?: string;
readonly sourceAmount?: number;
readonly destinationAmount?: number;
readonly tagIds?: string[];
readonly comment?: string;
}
+29
View File
@@ -33,6 +33,9 @@ import {
import {
type ExportTransactionDataRequest
} from '@/models/data_management.ts';
import type {
RecognizedReceiptImageResponse
} from '@/models/large_language_model.ts';
import {
getUserTransactionDraft,
@@ -1157,6 +1160,31 @@ export const useTransactionsStore = defineStore('transactions', () => {
});
}
function recognizeReceiptImage({ imageFile }: { imageFile: File }): Promise<RecognizedReceiptImageResponse> {
return new Promise((resolve, reject) => {
services.recognizeReceiptImage({ imageFile }).then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
reject({ message: 'Unable to recognize image' });
return;
}
resolve(data.result);
}).catch(error => {
logger.error('failed to recognize image', error);
if (error.response && error.response.data && error.response.data.errorMessage) {
reject({ error: error.response.data });
} else if (!error.processed) {
reject({ message: 'Unable to recognize image' });
} else {
reject(error);
}
});
});
}
function parseImportDsvFile({ fileType, fileEncoding, importFile }: { fileType: string, fileEncoding?: string, importFile: File }): Promise<string[][]> {
return new Promise((resolve, reject) => {
services.parseImportDsvFile({ fileType, fileEncoding, importFile }).then(response => {
@@ -1370,6 +1398,7 @@ export const useTransactionsStore = defineStore('transactions', () => {
getTransaction,
saveTransaction,
deleteTransaction,
recognizeReceiptImage,
parseImportDsvFile,
parseImportTransaction,
importTransactions,
+41 -4
View File
@@ -63,11 +63,16 @@
<v-btn class="ms-3" color="default" variant="outlined"
:disabled="loading || !canAddTransaction" @click="add()">
{{ tt('Add') }}
<v-menu activator="parent" :open-on-hover="true" v-if="allTransactionTemplates && allTransactionTemplates.length">
<v-menu activator="parent" :open-on-hover="true" v-if="isTransactionFromAIImageRecognitionEnabled() || (allTransactionTemplates && allTransactionTemplates.length)">
<v-list>
<v-list-item :title="template.name"
<v-list-item key="AIImageRecognition"
:title="tt('AI Image Recognition')"
:prepend-icon="mdiMagicStaff"
v-if="isTransactionFromAIImageRecognitionEnabled()"
@click="addByRecognizingImage"></v-list-item>
<v-list-item :key="template.id"
:title="template.name"
:prepend-icon="mdiTextBoxOutline"
:key="template.id"
v-for="template in allTransactionTemplates"
@click="add(template)"></v-list-item>
</v-list>
@@ -620,6 +625,7 @@
@error="onShowDateRangeError" />
<edit-dialog ref="editDialog" :type="TransactionEditPageType.Transaction" />
<a-i-image-recognition-dialog ref="aiImageRecognitionDialog" />
<import-dialog ref="importDialog" :persistent="true" />
<v-dialog width="800" v-model="showFilterAccountDialog">
@@ -647,6 +653,7 @@ import PaginationButtons from '@/components/desktop/PaginationButtons.vue';
import ConfirmDialog from '@/components/desktop/ConfirmDialog.vue';
import SnackBar from '@/components/desktop/SnackBar.vue';
import EditDialog from './list/dialogs/EditDialog.vue';
import AIImageRecognitionDialog from './list/dialogs/AIImageRecognitionDialog.vue';
import ImportDialog from './import/ImportDialog.vue';
import AccountFilterSettingsCard from '@/views/desktop/common/cards/AccountFilterSettingsCard.vue';
import CategoryFilterSettingsCard from '@/views/desktop/common/cards/CategoryFilterSettingsCard.vue';
@@ -716,7 +723,7 @@ import {
categoryTypeToTransactionType,
transactionTypeToCategoryType
} from '@/lib/category.ts';
import { isDataExportingEnabled, isDataImportingEnabled } from '@/lib/server_settings.ts';
import { isDataExportingEnabled, isDataImportingEnabled, isTransactionFromAIImageRecognitionEnabled } from '@/lib/server_settings.ts';
import { startDownloadFile } from '@/lib/ui/common.ts';
import { scrollToSelectedItem } from '@/lib/ui/desktop.ts';
import logger from '@/lib/logger.ts';
@@ -738,6 +745,7 @@ import {
mdiMinusBoxMultipleOutline,
mdiCloseBoxMultipleOutline,
mdiPound,
mdiMagicStaff,
mdiTextBoxOutline
} from '@mdi/js';
@@ -760,6 +768,7 @@ const props = defineProps<TransactionListProps>();
type ConfirmDialogType = InstanceType<typeof ConfirmDialog>;
type SnackBarType = InstanceType<typeof SnackBar>;
type EditDialogType = InstanceType<typeof EditDialog>;
type AIImageRecognitionDialogType = InstanceType<typeof AIImageRecognitionDialog>;
type ImportDialogType = InstanceType<typeof ImportDialog>;
interface TransactionTemplateWithIcon {
@@ -859,6 +868,7 @@ const tagFilterMenu = useTemplateRef<VMenu>('tagFilterMenu');
const confirmDialog = useTemplateRef<ConfirmDialogType>('confirmDialog');
const snackbar = useTemplateRef<SnackBarType>('snackbar');
const editDialog = useTemplateRef<EditDialogType>('editDialog');
const aiImageRecognitionDialog = useTemplateRef<AIImageRecognitionDialogType>('aiImageRecognitionDialog');
const importDialog = useTemplateRef<ImportDialogType>('importDialog');
const activeTab = ref<string>('transactionPage');
@@ -1597,6 +1607,33 @@ function add(template?: TransactionTemplate): void {
});
}
function addByRecognizingImage(): void {
aiImageRecognitionDialog.value?.open().then(result => {
editDialog.value?.open({
time: result.time,
type: result.type,
categoryId: result.categoryId,
accountId: result.sourceAccountId,
destinationAccountId: result.destinationAccountId,
amount: result.sourceAmount,
destinationAmount: result.destinationAmount,
tagIds: result.tagIds ? result.tagIds.join(',') : undefined,
comment: result.comment,
noTransactionDraft: true
}).then(result => {
if (result && result.message) {
snackbar.value?.showMessage(result.message);
}
reload(false, false);
}).catch(error => {
if (error) {
snackbar.value?.showError(error);
}
});
});
}
function importTransaction(): void {
importDialog.value?.open().then(() => {
reload(false, false);
@@ -0,0 +1,208 @@
<template>
<v-dialog width="800" :persistent="loading || recognizing || !!imageFile" v-model="showState">
<v-card class="pa-2 pa-sm-4 pa-md-4">
<template #title>
<div class="d-flex align-center justify-center">
<h4 class="text-h4">{{ tt('AI Image Recognition') }}</h4>
</div>
</template>
<v-card-text class="d-flex justify-center w-100 my-md-4 pt-0">
<div class="w-100 border position-relative"
@dragenter.prevent="onDragEnter"
@dragover.prevent
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop">
<div class="d-flex w-100 fill-height justify-center align-center justify-content-center"
:class="{ 'dropzone': true, 'dropzone-dragover': isDragOver }" style="height: 480px">
<h3 v-if="!imageFile && !isDragOver">{{ tt('Drag and drop a receipt or transaction image here, or click to select one') }}</h3>
<h3 v-if="isDragOver">{{ tt('Release to load image') }}</h3>
</div>
<v-img height="480px" :class="{ 'cursor-pointer': !loading || !recognizing || !isDragOver }"
:src="imageSrc" @click="showOpenImageDialog">
<template #placeholder>
<div class="w-100 fill-height bg-grey-200"></div>
</template>
</v-img>
</div>
</v-card-text>
<v-card-text class="overflow-y-visible">
<div ref="buttonContainer" class="w-100 d-flex justify-center gap-4">
<v-btn :disabled="loading || recognizing || !imageFile" @click="recognize">
{{ tt('Recognize') }}
<v-progress-circular indeterminate size="22" class="ms-2" v-if="recognizing"></v-progress-circular>
</v-btn>
<v-btn color="secondary" variant="tonal" :disabled="loading || recognizing"
@click="cancel">{{ tt('Cancel') }}</v-btn>
</div>
</v-card-text>
</v-card>
</v-dialog>
<snack-bar ref="snackbar" />
<input ref="imageInput" type="file" style="display: none" :accept="SUPPORTED_IMAGE_EXTENSIONS" @change="openImage($event)" />
</template>
<script setup lang="ts">
import SnackBar from '@/components/desktop/SnackBar.vue';
import { ref, useTemplateRef } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
import { useTransactionsStore } from '@/stores/transaction.ts';
import { KnownFileType } from '@/core/file.ts';
import { SUPPORTED_IMAGE_EXTENSIONS } from '@/consts/file.ts';
import type { RecognizedReceiptImageResponse } from '@/models/large_language_model.ts';
import { compressJpgImage } from '@/lib/ui/common.ts';
import logger from '@/lib/logger.ts';
type SnackBarType = InstanceType<typeof SnackBar>;
const { tt } = useI18n();
const transactionsStore = useTransactionsStore();
const snackbar = useTemplateRef<SnackBarType>('snackbar');
const imageInput = useTemplateRef<HTMLInputElement>('imageInput');
let resolveFunc: ((response: RecognizedReceiptImageResponse) => void) | null = null;
let rejectFunc: ((reason?: unknown) => void) | null = null;
const showState = ref<boolean>(false);
const loading = ref<boolean>(false);
const recognizing = ref<boolean>(false);
const imageFile = ref<File | null>(null);
const imageSrc = ref<string | undefined>(undefined);
const isDragOver = ref<boolean>(false);
function loadImage(file: File): void {
compressJpgImage(file, 1280, 1280, 0.8).then(blob => {
imageFile.value = KnownFileType.JPG.createFileFromBlob(blob, "image");
imageSrc.value = URL.createObjectURL(blob);
}).catch(error => {
imageFile.value = null;
imageSrc.value = undefined;
logger.error('failed to compress image', error);
snackbar.value?.showError('Unable to load image');
});
}
function open(): Promise<RecognizedReceiptImageResponse> {
showState.value = true;
loading.value = false;
recognizing.value = false;
imageFile.value = null;
imageSrc.value = undefined;
return new Promise((resolve, reject) => {
resolveFunc = resolve;
rejectFunc = reject;
});
}
function showOpenImageDialog(): void {
if (loading.value || recognizing.value || isDragOver.value) {
return;
}
imageInput.value?.click();
}
function openImage(event: Event): void {
if (!event || !event.target) {
return;
}
const el = event.target as HTMLInputElement;
if (!el.files || !el.files.length || !el.files[0]) {
return;
}
const image = el.files[0] as File;
el.value = '';
loadImage(image);
}
function recognize(): void {
if (loading.value || recognizing.value || !imageFile.value) {
return;
}
recognizing.value = true;
transactionsStore.recognizeReceiptImage({
imageFile: imageFile.value
}).then(response => {
resolveFunc?.(response);
showState.value = false;
recognizing.value = false;
}).catch(error => {
recognizing.value = false;
if (!error.processed) {
snackbar.value?.showError(error);
}
});
}
function cancel(): void {
rejectFunc?.();
showState.value = false;
loading.value = false;
recognizing.value = false;
imageFile.value = null;
imageSrc.value = undefined;
}
function onDragEnter(): void {
if (loading.value || recognizing.value) {
return;
}
isDragOver.value = true;
}
function onDragLeave(): void {
isDragOver.value = false;
}
function onDrop(event: DragEvent): void {
if (loading.value || recognizing.value) {
return;
}
isDragOver.value = false;
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length && event.dataTransfer.files[0]) {
loadImage(event.dataTransfer.files[0] as File);
}
}
defineExpose({
open
});
</script>
<style>
.dropzone {
position: absolute;
top: 0;
left: 0;
width: 100%;
pointer-events: none;
border-radius: 8px;
z-index: 10;
}
.dropzone-dragover {
border: 6px dashed rgba(var(--v-border-color),var(--v-border-opacity));
}
</style>
+62 -2
View File
@@ -188,7 +188,14 @@
<f7-popover class="template-popover-menu" target-el="#homepage-add-button"
v-model:opened="showTransactionTemplatePopover">
<f7-list dividers v-if="allTransactionTemplates">
<f7-list-item :title="template.name" :key="template.id"
<f7-list-item key="AIImageRecognition" :title="tt('AI Image Recognition')"
@click="showAIReceiptImageRecognitionSheet = true; showTransactionTemplatePopover = false"
v-if="isTransactionFromAIImageRecognitionEnabled()">
<template #media>
<f7-icon f7="wand_stars"></f7-icon>
</template>
</f7-list-item>
<f7-list-item :key="template.id" :title="template.name"
:link="'/transaction/add?templateId=' + template.id"
v-for="template in allTransactionTemplates">
<template #media>
@@ -197,11 +204,15 @@
</f7-list-item>
</f7-list>
</f7-popover>
<a-i-image-recognition-sheet v-model:show="showAIReceiptImageRecognitionSheet"
@recognition:change="onReceiptRecognitionChanged"/>
</f7-page>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import type { Router } from 'framework7/types';
import { useI18n } from '@/locales/helpers.ts';
import { useI18nUIComponents } from '@/lib/ui/mobile.ts';
@@ -215,8 +226,14 @@ import { useOverviewStore } from '@/stores/overview.ts';
import { DateRange } from '@/core/datetime.ts';
import { TemplateType } from '@/core/template.ts';
import { TransactionTemplate } from '@/models/transaction_template.ts';
import type { RecognizedReceiptImageResponse } from '@/models/large_language_model.ts';
import { isUserLogined, isUserUnlocked } from '@/lib/userstate.ts';
import { isTransactionFromAIImageRecognitionEnabled } from '@/lib/server_settings.ts';
const props = defineProps<{
f7router: Router.Router;
}>();
const { tt } = useI18n();
const { showToast } = useI18nUIComponents();
@@ -236,6 +253,7 @@ const overviewStore = useOverviewStore();
const loading = ref<boolean>(true);
const showTransactionTemplatePopover = ref<boolean>(false);
const showAIReceiptImageRecognitionSheet = ref<boolean>(false);
const allTransactionTemplates = computed<TransactionTemplate[]>(() => {
const allTemplates = transactionTemplatesStore.allVisibleTemplates;
@@ -243,7 +261,7 @@ const allTransactionTemplates = computed<TransactionTemplate[]>(() => {
});
function openTransactionTemplatePopover(): void {
if (allTransactionTemplates.value && allTransactionTemplates.value.length) {
if (isTransactionFromAIImageRecognitionEnabled() || (allTransactionTemplates.value && allTransactionTemplates.value.length)) {
showTransactionTemplatePopover.value = true;
}
}
@@ -291,6 +309,48 @@ function reload(done?: () => void): void {
});
}
function onReceiptRecognitionChanged(result: RecognizedReceiptImageResponse): void {
const params: string[] = [];
if (result.type) {
params.push(`type=${result.type}`);
}
if (result.time) {
params.push(`time=${result.time}`);
}
if (result.categoryId) {
params.push(`categoryId=${result.categoryId}`);
}
if (result.sourceAccountId) {
params.push(`accountId=${result.sourceAccountId}`);
}
if (result.destinationAccountId) {
params.push(`destinationAccountId=${result.destinationAccountId}`);
}
if (result.sourceAmount) {
params.push(`amount=${result.sourceAmount}`);
}
if (result.destinationAmount) {
params.push(`destinationAmount=${result.destinationAmount}`);
}
if (result.tagIds) {
params.push(`tagIds=${result.tagIds.join(',')}`);
}
if (result.comment) {
params.push(`comment=${encodeURIComponent(result.comment)}`);
}
props.f7router.navigate(`/transaction/add?${params.join('&')}`);
}
function onPageAfterIn(): void {
if (!loading.value) {
reload();