AI-accounting-soft-uniApp/pages/add/add.vue
2025-12-12 16:49:06 +08:00

896 lines
21 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<scroll-view scroll-y class="page-scroll" :scroll-top="pageScrollTop">
<view class="container">
<view class="tabs">
<view class="tab" :class="{ active: activeTab === 'manual' }" @click="activeTab = 'manual'">
手动记账
</view>
<view class="tab" :class="{ active: activeTab === 'ocr' }" @click="activeTab = 'ocr'">
OCR识别
</view>
</view>
<!-- 手动记账 -->
<view v-if="activeTab === 'manual'" class="form">
<view class="form-item">
<text class="label">账单类型</text>
<view class="type-selector-compact">
<view
class="type-option-compact"
:class="{ active: form.type === 1 }"
@click="form.type = 1"
>
<text class="type-icon-compact">📤</text>
<text class="type-text-compact">支出</text>
</view>
<view
class="type-option-compact"
:class="{ active: form.type === 2 }"
@click="form.type = 2"
>
<text class="type-icon-compact">📥</text>
<text class="type-text-compact">收入</text>
</view>
</view>
</view>
<view class="form-item">
<text class="label">分类</text>
<view v-if="categories.length > 0" class="category-grid">
<view
v-for="category in categories"
:key="category.id"
class="category-icon-item"
:class="{ active: selectedCategory?.id === category.id }"
@click="selectCategory(category)"
>
<view class="category-icon-circle">
<text class="category-icon-emoji">{{ category.icon || '📦' }}</text>
</view>
<text class="category-icon-name">{{ category.name }}</text>
</view>
</view>
<view v-else class="category-empty">
<text>暂无分类,请先创建分类</text>
</view>
</view>
<view class="form-item">
<text class="label">金额</text>
<input
v-model="form.amount"
type="number"
inputmode="decimal"
placeholder="请输入金额"
@input="onAmountInput"
@focus="onAmountFocus"
/>
</view>
<view class="form-item">
<text class="label">日期</text>
<picker mode="date" v-model="form.billDate" @change="onDateChange">
<view class="picker">
<text>{{ form.billDate || '选择日期' }}</text>
<text class="arrow">></text>
</view>
</picker>
</view>
<view class="form-item">
<text class="label">备注</text>
<textarea v-model="form.description" placeholder="请输入备注(可选)" class="textarea" />
</view>
<button class="submit-btn" @click="submitBill">保存</button>
</view>
<!-- OCR识别 -->
<view v-if="activeTab === 'ocr'" class="ocr-container">
<!-- 图片预览区域(可折叠) -->
<view class="image-section">
<view class="section-header" @click="toggleImageSection">
<text class="section-title">图片预览</text>
<text class="expand-icon">{{ imageSectionExpanded ? '▼' : '▶' }}</text>
</view>
<view v-if="imageSectionExpanded" class="section-content">
<view class="upload-area" @click="chooseImage">
<image v-if="imagePath" :src="imagePath" class="preview-image" mode="aspectFit" />
<view v-else class="upload-placeholder">
<text class="upload-icon">📷</text>
<text>点击选择图片</text>
</view>
</view>
</view>
</view>
<button v-if="imagePath && ocrResultList.length === 0" class="recognize-btn" @click="recognizeImage" :loading="recognizing">
识别账单
</button>
<!-- OCR识别结果列表可折叠 -->
<view v-if="ocrResultList.length > 0" class="ocr-result-list-container">
<view class="section-header" @click="toggleResultList">
<text class="list-title">识别到 {{ ocrResultList.length }} 条账单记录</text>
<text class="expand-icon">{{ resultListExpanded ? '▼' : '▶' }}</text>
</view>
<view v-if="resultListExpanded" class="section-content">
<view class="result-list-wrapper">
<view v-for="(item, index) in ocrResultList" :key="index" class="result-card" :class="{ active: selectedOcrResult === item, expanded: expandedCards.has(index) }">
<view class="result-header" @click.stop="toggleCard(index)">
<view class="header-left">
<text class="result-index">#{{ index + 1 }}</text>
<text class="result-value amount summary">¥{{ item.amount }}</text>
</view>
<view class="header-right">
<text v-if="item.confidence" class="result-confidence">置信度: {{ (item.confidence * 100).toFixed(1) }}%</text>
<text class="expand-icon">{{ expandedCards.has(index) ? '▼' : '▶' }}</text>
</view>
</view>
<view v-if="expandedCards.has(index)" class="result-details">
<view class="result-item">
<text class="result-label">金额:</text>
<text class="result-value amount">¥{{ item.amount ? Math.abs(item.amount).toFixed(2) : '0.00' }}</text>
</view>
<view v-if="item.merchant" class="result-item">
<text class="result-label">商户:</text>
<text class="result-value">{{ item.merchant }}</text>
</view>
<view v-if="item.date" class="result-item">
<text class="result-label">日期:</text>
<text class="result-value">{{ formatOcrDate(item.date) }}</text>
</view>
<button class="select-btn" @click.stop="goToManualWithOcrData(item)">选择此条</button>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { createBill } from '../../api/bill'
import { getCategories } from '../../api/category'
import { recognizeImage as ocrRecognize } from '../../api/ocr'
import { formatDate } from '../../utils/date'
const activeTab = ref('manual')
const categories = ref([])
const selectedCategory = ref(null)
const imagePath = ref('')
const recognizing = ref(false)
const ocrResultList = ref([])
const selectedOcrResult = ref(null)
const expandedCards = ref(new Set()) // 存储展开的卡片索引
const imageSectionExpanded = ref(true) // 图片预览区域展开状态
const resultListExpanded = ref(true) // 结果列表展开状态
const pageScrollTop = ref(0) // 页面滚动位置
const form = ref({
type: 1, // 1-支出2-收入,默认支出
categoryId: null,
amount: '',
description: '',
billDate: formatDate(new Date()),
imageUrl: ''
})
const loadCategories = async () => {
try {
// 根据账单类型加载对应的分类1-支出2-收入)
const type = form.value.type || 1
const data = await getCategories(type)
categories.value = data || []
// 如果当前选中的分类不在新类型中,清空选择
if (selectedCategory.value && selectedCategory.value.type !== type) {
selectedCategory.value = null
form.value.categoryId = null
}
} catch (error) {
console.error('加载分类失败', error)
}
}
// 监听账单类型变化,重新加载分类
watch(() => form.value.type, (newType) => {
if (newType) {
loadCategories()
}
})
// 监听账单类型变化,重新加载分类
watch(() => form.value.type, (newType) => {
if (newType) {
loadCategories()
}
})
const chooseImage = () => {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['camera', 'album'],
success: (res) => {
imagePath.value = res.tempFilePaths[0]
ocrResultList.value = []
selectedOcrResult.value = null
expandedCards.value.clear()
form.value.amount = ''
}
})
}
const recognizeImage = async () => {
if (!imagePath.value) {
uni.showToast({
title: '请先选择图片',
icon: 'none'
})
return
}
recognizing.value = true
try {
const resultList = await ocrRecognize(imagePath.value)
// 后端返回的是数组
ocrResultList.value = Array.isArray(resultList) ? resultList : [resultList]
if (ocrResultList.value.length === 0) {
uni.showToast({
title: '未识别到账单信息',
icon: 'none'
})
} else {
uni.showToast({
title: `识别到 ${ocrResultList.value.length} 条记录`,
icon: 'success'
})
// 如果只有一条,自动选中并展开
if (ocrResultList.value.length === 1) {
expandedCards.value.add(0)
selectOcrResult(ocrResultList.value[0])
} else if (ocrResultList.value.length > 0) {
// 多条记录时,默认展开第一条
expandedCards.value.add(0)
}
}
} catch (error) {
console.error('OCR识别失败:', error)
uni.showToast({
title: error.message || '识别失败,请重试',
icon: 'none'
})
} finally {
recognizing.value = false
}
}
const scrollTop = ref(0)
const toggleImageSection = () => {
imageSectionExpanded.value = !imageSectionExpanded.value
}
const toggleResultList = () => {
resultListExpanded.value = !resultListExpanded.value
}
const toggleCard = (index) => {
if (expandedCards.value.has(index)) {
expandedCards.value.delete(index)
} else {
expandedCards.value.add(index)
}
}
const goToManualWithOcrData = (item) => {
// 根据金额正负判断账单类型
// 负数 -> 支出type: 1正数 -> 收入type: 2
const amount = parseFloat(item.amount) || 0
const billType = amount < 0 ? 1 : 2 // 1-支出2-收入
// 保存OCR识别数据到本地存储
const ocrData = {
amount: Math.abs(amount).toString(), // 金额取绝对值
billDate: item.date ? formatOcrDate(item.date) : formatDate(new Date()),
description: item.merchant || '',
type: billType, // 根据金额正负设置类型
fromOcr: true
}
uni.setStorageSync('ocrFormData', ocrData)
// 切换到手动记账页面
activeTab.value = 'manual'
// 填充表单数据
form.value.amount = ocrData.amount
form.value.billDate = ocrData.billDate
form.value.description = ocrData.description
form.value.type = billType // 根据金额正负自动设置类型
// 加载对应类型的分类
loadCategories()
uni.showToast({
title: `已填充到手动记账(${billType === 1 ? '支出' : '收入'}`,
icon: 'success'
})
}
const formatOcrDate = (dateStr) => {
if (!dateStr) return formatDate(new Date())
// 如果已经是 YYYY-MM-DD 格式,直接返回
if (typeof dateStr === 'string' && dateStr.match(/^\d{4}-\d{2}-\d{2}/)) {
return dateStr.split('T')[0] // 处理 LocalDateTime 格式
}
// 尝试解析日期
try {
const date = new Date(dateStr)
return formatDate(date)
} catch (e) {
return formatDate(new Date())
}
}
const selectCategory = (category) => {
selectedCategory.value = category
form.value.categoryId = category.id
}
const onDateChange = (e) => {
form.value.billDate = e.detail.value
}
const onAmountInput = (e) => {
// 确保只能输入数字和小数点
let value = e.detail.value || ''
// 移除非数字和小数点的字符
value = value.replace(/[^\d.]/g, '')
// 确保只有一个小数点
const parts = value.split('.')
if (parts.length > 2) {
value = parts[0] + '.' + parts.slice(1).join('')
}
// 限制小数位数为2位
if (parts.length === 2 && parts[1].length > 2) {
value = parts[0] + '.' + parts[1].substring(0, 2)
}
form.value.amount = value
}
const onAmountFocus = () => {
// 聚焦时确保输入框可交互
console.log('金额输入框聚焦')
}
const submitBill = async () => {
if (!form.value.type) {
uni.showToast({
title: '请选择账单类型',
icon: 'none'
})
return
}
if (!form.value.categoryId) {
uni.showToast({
title: '请选择分类',
icon: 'none'
})
return
}
if (!form.value.amount || parseFloat(form.value.amount) <= 0) {
uni.showToast({
title: '请输入有效金额',
icon: 'none'
})
return
}
try {
await createBill({
type: form.value.type,
categoryId: form.value.categoryId,
amount: parseFloat(form.value.amount),
description: form.value.description,
billDate: form.value.billDate,
imageUrl: form.value.imageUrl
})
uni.showToast({
title: '保存成功',
icon: 'success'
})
// 重置表单
form.value = {
type: 1,
categoryId: null,
amount: '',
description: '',
billDate: formatDate(new Date()),
imageUrl: ''
}
selectedCategory.value = null
ocrResultList.value = []
selectedOcrResult.value = null
expandedCards.value.clear()
imagePath.value = ''
// 重新加载分类
loadCategories()
// 触发首页刷新
uni.$emit('refreshBills')
// 跳转到首页
setTimeout(() => {
uni.switchTab({
url: '/pages/index/index'
})
}, 1500)
} catch (error) {
console.error('保存失败:', error)
uni.showToast({
title: error.message || '保存失败',
icon: 'none'
})
}
}
onMounted(() => {
loadCategories()
// 检查是否有OCR数据需要填充
const ocrFormData = uni.getStorageSync('ocrFormData')
if (ocrFormData && ocrFormData.fromOcr) {
form.value.amount = ocrFormData.amount || ''
form.value.billDate = ocrFormData.billDate || formatDate(new Date())
form.value.description = ocrFormData.description || ''
// 清除临时数据
uni.removeStorageSync('ocrFormData')
// 切换到手动记账页面
activeTab.value = 'manual'
}
})
</script>
<style scoped>
.page-scroll {
height: 100vh;
width: 100%;
padding-bottom: calc(50px + env(safe-area-inset-bottom));
}
.container {
min-height: calc(100vh - 100rpx);
background: #f5f5f5;
padding: 20rpx;
padding-bottom: calc(170rpx + env(safe-area-inset-bottom));
}
.tabs {
display: flex;
background: #fff;
border-radius: 12rpx;
margin-bottom: 20rpx;
padding: 10rpx;
}
.tab {
flex: 1;
text-align: center;
padding: 20rpx;
border-radius: 8rpx;
color: #666;
font-size: 28rpx;
}
.tab.active {
background: #667eea;
color: #fff;
}
.form {
background: #fff;
border-radius: 12rpx;
padding: 30rpx;
}
.form-item {
margin-bottom: 30rpx;
}
.label {
display: block;
font-size: 28rpx;
color: #333;
margin-bottom: 20rpx;
}
.type-selector-compact {
display: flex;
gap: 15rpx;
justify-content: center;
}
.type-option-compact {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 15rpx 30rpx;
background: #f8f8f8;
border-radius: 8rpx;
border: 2rpx solid transparent;
transition: all 0.3s;
min-width: 120rpx;
}
.type-option-compact.active {
background: #f0f7ff;
border-color: #667eea;
}
.type-icon-compact {
font-size: 32rpx;
margin-bottom: 5rpx;
}
.type-text-compact {
font-size: 24rpx;
color: #333;
}
.type-option-compact.active .type-text-compact {
color: #667eea;
font-weight: bold;
}
.category-grid {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
margin-top: 10rpx;
}
.category-icon-item {
display: flex;
flex-direction: column;
align-items: center;
width: calc(25% - 15rpx);
margin-bottom: 10rpx;
}
.category-icon-circle {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
background: #f8f8f8;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8rpx;
border: 3rpx solid transparent;
transition: all 0.3s;
}
.category-icon-item.active .category-icon-circle {
background: #f0f7ff;
border-color: #667eea;
transform: scale(1.1);
}
.category-icon-emoji {
font-size: 48rpx;
line-height: 1;
}
.category-icon-name {
font-size: 22rpx;
color: #666;
text-align: center;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-icon-item.active .category-icon-name {
color: #667eea;
font-weight: bold;
}
.category-empty {
text-align: center;
padding: 40rpx 0;
color: #999;
font-size: 26rpx;
}
.input, .textarea {
width: 100%;
padding: 20rpx;
background: #f8f8f8;
border-radius: 8rpx;
font-size: 28rpx;
box-sizing: border-box;
border: none;
outline: none;
-webkit-appearance: none;
-webkit-tap-highlight-color: transparent;
pointer-events: auto;
user-select: text;
-webkit-user-select: text;
}
.textarea {
min-height: 200rpx;
}
.category-selector, .picker {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx;
background: #f8f8f8;
border-radius: 8rpx;
}
.placeholder {
color: #999;
}
.arrow {
color: #999;
}
.submit-btn {
width: 100%;
height: 88rpx;
background: #667eea;
color: #fff;
border-radius: 12rpx;
font-size: 32rpx;
margin-top: 40rpx;
margin-bottom: 20rpx;
border: none;
}
.ocr-container {
background: #fff;
border-radius: 12rpx;
padding: 30rpx;
}
.image-section {
margin-bottom: 30rpx;
background: #fff;
border-radius: 12rpx;
overflow: hidden;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx;
background: #f8f8f8;
border-radius: 12rpx;
cursor: pointer;
user-select: none;
transition: background 0.3s;
}
.section-header:active {
background: #f0f0f0;
}
.section-title {
font-size: 28rpx;
font-weight: bold;
color: #333;
}
.section-content {
padding: 20rpx 0;
animation: slideDown 0.3s ease-out;
}
.upload-area {
width: 100%;
height: 400rpx;
border: 2rpx dashed #ddd;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 30rpx;
}
.preview-image {
width: 100%;
height: 100%;
border-radius: 12rpx;
}
.upload-placeholder {
text-align: center;
color: #999;
display: flex;
flex-direction: column;
align-items: center;
}
.upload-icon {
font-size: 80rpx;
display: block;
margin-bottom: 20rpx;
}
.recognize-btn {
width: 100%;
height: 88rpx;
background: #19be6b;
color: #fff;
border-radius: 12rpx;
font-size: 32rpx;
margin-bottom: 30rpx;
border: none;
}
.ocr-result-list-container {
margin-top: 30rpx;
background: #fff;
border-radius: 12rpx;
overflow: hidden;
}
.list-title {
font-size: 28rpx;
color: #333;
font-weight: bold;
}
.result-list-wrapper {
max-height: 600rpx;
overflow-y: auto;
padding: 0 20rpx 20rpx 20rpx;
-webkit-overflow-scrolling: touch;
}
.result-card {
background: #fff;
border: 2rpx solid #e0e0e0;
border-radius: 12rpx;
padding: 20rpx;
margin-bottom: 20rpx;
transition: all 0.3s;
overflow: hidden;
}
.result-card.active {
border-color: #667eea;
background: #f0f7ff;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
}
.header-left {
display: flex;
align-items: center;
flex: 1;
}
.header-right {
display: flex;
align-items: center;
gap: 15rpx;
}
.result-index {
font-size: 24rpx;
color: #667eea;
font-weight: bold;
margin-right: 15rpx;
}
.result-value.amount.summary {
font-size: 30rpx;
font-weight: bold;
color: #fa3534;
}
.result-confidence {
font-size: 22rpx;
color: #999;
}
.expand-icon {
font-size: 24rpx;
color: #667eea;
transition: transform 0.3s;
margin-left: 10rpx;
}
.result-card.expanded .expand-icon {
transform: rotate(90deg);
}
.result-details {
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f0f0f0;
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 500rpx;
}
}
.result-item {
display: flex;
padding: 10rpx 0;
align-items: center;
}
.result-label {
font-size: 26rpx;
color: #666;
width: 100rpx;
flex-shrink: 0;
}
.result-value {
font-size: 28rpx;
color: #333;
flex: 1;
}
.result-value.amount {
font-weight: bold;
color: #fa3534;
font-size: 32rpx;
}
.select-btn {
width: 100%;
height: 70rpx;
background: #667eea;
color: #fff;
border-radius: 8rpx;
font-size: 26rpx;
margin-top: 20rpx;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
.ocr-result {
margin-top: 30rpx;
padding-top: 30rpx;
border-top: 2rpx solid #667eea;
}
</style>